From 3a93a5a66e0877cdf1bc811ee68c5e96b8884d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=94=EB=83=90?= Date: Mon, 7 Sep 2026 23:32:35 +0900 Subject: [PATCH] wow --- .gitignore | 5 + .idea/.idea.ADOFAIRenderer/.idea/.gitignore | 15 + .../.idea/indexLayout.xml | 8 + ADOFAIRenderer.sln | 16 + ADOFAIRenderer/ADOFAIRenderer.csproj | 37 + ADOFAIRenderer/Info.json | 1 + ADOFAIRenderer/Main.cs | 134 +++ ADOFAIRenderer/Patches/ConductorPatch.cs | 102 +++ ADOFAIRenderer/Patches/GameplayPatch.cs | 191 +++++ ADOFAIRenderer/Patches/TweenClockPatch.cs | 35 + ADOFAIRenderer/Properties/AssemblyInfo.cs | 35 + ADOFAIRenderer/Renderer/FFmpegEncoder.cs | 159 ++++ ADOFAIRenderer/Renderer/FrameCapture.cs | 227 ++++++ ADOFAIRenderer/Renderer/GameAudioCapture.cs | 101 +++ ADOFAIRenderer/Renderer/RenderClock.cs | 28 + ADOFAIRenderer/Renderer/RendererController.cs | 769 ++++++++++++++++++ ADOFAIRenderer/Renderer/RendererRpcServer.cs | 460 +++++++++++ ADOFAIRenderer/RendererSettings.cs | 155 ++++ ADOFAIRenderer/UI/RendererWindow.cs | 62 ++ Tests/Program.cs | 100 +++ Tests/RendererTests.csproj | 17 + build.ps1 | 66 ++ 22 files changed, 2723 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.idea.ADOFAIRenderer/.idea/.gitignore create mode 100644 .idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml create mode 100644 ADOFAIRenderer.sln create mode 100644 ADOFAIRenderer/ADOFAIRenderer.csproj create mode 100644 ADOFAIRenderer/Info.json create mode 100644 ADOFAIRenderer/Main.cs create mode 100644 ADOFAIRenderer/Patches/ConductorPatch.cs create mode 100644 ADOFAIRenderer/Patches/GameplayPatch.cs create mode 100644 ADOFAIRenderer/Patches/TweenClockPatch.cs create mode 100644 ADOFAIRenderer/Properties/AssemblyInfo.cs create mode 100644 ADOFAIRenderer/Renderer/FFmpegEncoder.cs create mode 100644 ADOFAIRenderer/Renderer/FrameCapture.cs create mode 100644 ADOFAIRenderer/Renderer/GameAudioCapture.cs create mode 100644 ADOFAIRenderer/Renderer/RenderClock.cs create mode 100644 ADOFAIRenderer/Renderer/RendererController.cs create mode 100644 ADOFAIRenderer/Renderer/RendererRpcServer.cs create mode 100644 ADOFAIRenderer/RendererSettings.cs create mode 100644 ADOFAIRenderer/UI/RendererWindow.cs create mode 100644 Tests/Program.cs create mode 100644 Tests/RendererTests.csproj create mode 100644 build.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/.idea/.idea.ADOFAIRenderer/.idea/.gitignore b/.idea/.idea.ADOFAIRenderer/.idea/.gitignore new file mode 100644 index 0000000..9c37bcc --- /dev/null +++ b/.idea/.idea.ADOFAIRenderer/.idea/.gitignore @@ -0,0 +1,15 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Rider ignored files +/modules.xml +/contentModel.xml +/projectSettingsUpdater.xml +/.idea.ADOFAIRenderer.iml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml b/.idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/ADOFAIRenderer.sln b/ADOFAIRenderer.sln new file mode 100644 index 0000000..d8445e0 --- /dev/null +++ b/ADOFAIRenderer.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADOFAIRenderer", "ADOFAIRenderer\ADOFAIRenderer.csproj", "{6D295B50-05F2-474C-BC70-B38B0FC0A3FF}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6D295B50-05F2-474C-BC70-B38B0FC0A3FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D295B50-05F2-474C-BC70-B38B0FC0A3FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D295B50-05F2-474C-BC70-B38B0FC0A3FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D295B50-05F2-474C-BC70-B38B0FC0A3FF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/ADOFAIRenderer/ADOFAIRenderer.csproj b/ADOFAIRenderer/ADOFAIRenderer.csproj new file mode 100644 index 0000000..88c6586 --- /dev/null +++ b/ADOFAIRenderer/ADOFAIRenderer.csproj @@ -0,0 +1,37 @@ + + + + Debug + {6D295B50-05F2-474C-BC70-B38B0FC0A3FF} + Libraryv4.8latest + ADOFAIRendererADOFAIRenderer + bin\$(Configuration)\true + $(MSBuildProjectDirectory)\..\packages\net48\build\ + C:\Program Files (x86)\Steam\steamapps\common\A Dance of Fire and Ice + $(GameDir)\A Dance of Fire and Ice_Data\Managed + $(ManagedDir)\UnityModManager + $(MSBuildProjectDirectory)\..\packages\net48\build\.NETFramework\v4.8 + + + + false + false + false + false + false + false + false + false + + $(ModManagerDir)\UnityModManager.dll + ..\packages\UnityModManager\lib\net35\UnityModManager.dllfalse + + + $(ModManagerDir)\0Harmony.dll + ..\packages\0Harmony.dllfalse + + + PreserveNewest + + + diff --git a/ADOFAIRenderer/Info.json b/ADOFAIRenderer/Info.json new file mode 100644 index 0000000..afe2538 --- /dev/null +++ b/ADOFAIRenderer/Info.json @@ -0,0 +1 @@ +{"Id":"ADOFAIRenderer","DisplayName":"ADOFAI Renderer","Author":"imnyang","Version":"0.5.3","ManagerVersion":"0.27.0","AssemblyName":"ADOFAIRenderer.dll","EntryMethod":"ADOFAIRenderer.Main.Load"} diff --git a/ADOFAIRenderer/Main.cs b/ADOFAIRenderer/Main.cs new file mode 100644 index 0000000..3dc0c7a --- /dev/null +++ b/ADOFAIRenderer/Main.cs @@ -0,0 +1,134 @@ +using System; +using System.Globalization; +using HarmonyLib; +using UnityModManagerNet; +using UnityEngine; +using UnityEngine.SceneManagement; +using ADOFAIRenderer.Renderer; +using ADOFAIRenderer.UI; +namespace ADOFAIRenderer +{ + public static class Main + { + internal static UnityModManager.ModEntry Entry; + internal static bool Enabled; + internal static bool RpcEnabled { get; private set; } + internal static int RpcPort { get; private set; } = 1108; + internal static RendererRpcServer RpcServer { get; private set; } + internal static RendererSettings Settings; + private static Harmony harmony; + private static GameObject host; + + public static bool Load(UnityModManager.ModEntry entry) + { + Entry = entry; + try + { + ReadCommandLineOptions(); + Settings = RendererSettings.Load(entry); + Settings.OnChange(); + harmony = new Harmony(entry.Info.Id); + harmony.PatchAll(typeof(Main).Assembly); + host = new GameObject("ADOFAI Renderer"); + UnityEngine.Object.DontDestroyOnLoad(host); + host.AddComponent(); + SceneManager.sceneLoaded += OnSceneLoaded; + Enabled = true; + StartRpcServer(); + entry.OnToggle = (mod, enabled) => + { + if (!enabled) RendererController.Instance?.StopAndClean(); + Enabled = enabled; + if (!enabled) StopRpcServer(); + else StartRpcServer(); + return true; + }; + entry.OnGUI = mod => + { + if (Settings == null) return; + UnityModManager.UI.DrawFields(ref Settings, mod, DrawFieldMask.Any, Settings.OnChange); + }; + entry.OnSaveGUI = mod => Settings?.Save(mod); + entry.OnUnload = mod => + { + RendererController.Instance?.StopAndClean(); + StopRpcServer(); + SceneManager.sceneLoaded -= OnSceneLoaded; + UnityEngine.Object.Destroy(host); + harmony.UnpatchAll(mod.Info.Id); + Settings = null; + return true; + }; + entry.Logger.Log("Renderer loaded. Unity " + Application.unityVersion); + return true; + } + catch (Exception ex) + { + if (host != null) UnityEngine.Object.Destroy(host); + harmony?.UnpatchAll(entry.Info.Id); + entry.Logger.Error(ex.ToString()); + return false; + } + } + + private static void ReadCommandLineOptions() + { + RpcEnabled = false; + RpcPort = 1108; + foreach (var argument in Environment.GetCommandLineArgs()) + { + if (string.Equals(argument, "--renderer-rpc", StringComparison.OrdinalIgnoreCase)) + { + RpcEnabled = true; + continue; + } + const string prefix = "--renderer-rpc-port="; + if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && int.TryParse(argument.Substring(prefix.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var port) + && port >= 1 && port <= 65535) + { + RpcPort = port; + RpcEnabled = true; + } + } + } + + private static void StartRpcServer() + { + if (!RpcEnabled || RpcServer != null || RendererController.Instance == null) return; + try + { + RpcServer = new RendererRpcServer(RendererController.Instance, RpcPort); + RpcServer.Start(); + } + catch (Exception ex) + { + RpcServer = null; + Entry.Logger.Error("Renderer RPC could not start on port " + RpcPort + ": " + ex); + } + } + + private static void StopRpcServer() + { + var server = RpcServer; + RpcServer = null; + try { server?.Dispose(); } catch (Exception ex) { Entry.Logger.Error("Renderer RPC shutdown: " + ex); } + } + + private static void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + if (!Enabled) return; + // ADOFAI's KillAll cleanup can remove DontDestroyOnLoad objects + // while switching between menu, editor, and gameplay scenes. + // Recreate our tiny host so RPC jobs survive that transition. + if (RendererController.Instance == null) + { + host = new GameObject("ADOFAI Renderer"); + UnityEngine.Object.DontDestroyOnLoad(host); + host.AddComponent(); + RpcServer?.Rebind(RendererController.Instance); + Entry.Logger.Log("Renderer host recreated after scene load: " + scene.name); + } + } + } +} diff --git a/ADOFAIRenderer/Patches/ConductorPatch.cs b/ADOFAIRenderer/Patches/ConductorPatch.cs new file mode 100644 index 0000000..48c8cc6 --- /dev/null +++ b/ADOFAIRenderer/Patches/ConductorPatch.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection.Emit; +using HarmonyLib; +using UnityEngine; +using ADOFAIRenderer.Renderer; + +namespace ADOFAIRenderer.Patches +{ + // Keep the game's beat propagation and deltaSongPos calculation. Replace only + // the DSP source, including its stall fallback, in the verified Update method. + [HarmonyPatch] + internal static class ConductorPatch + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(scrConductor), "Update"); + yield return AccessTools.Method(typeof(scrCountdown), "Update"); + } + static double DspTime() => RendererController.ControlsTime ? RendererController.Instance.Clock.DspTime : AudioSettings.dspTime; + static double UnscaledTime() => RendererController.ControlsTime ? RendererController.Instance.Clock.Time : Time.unscaledTimeAsDouble; + static float UnscaledDelta() => RendererController.ControlsTime ? 1f / RendererController.Instance.Clock.Fps : Time.unscaledDeltaTime; + static IEnumerable Transpiler(IEnumerable instructions) + { + var dsp = AccessTools.PropertyGetter(typeof(AudioSettings), nameof(AudioSettings.dspTime)); + var time = AccessTools.PropertyGetter(typeof(Time), nameof(Time.unscaledTimeAsDouble)); + var delta = AccessTools.PropertyGetter(typeof(Time), nameof(Time.unscaledDeltaTime)); + int replacements = 0; + foreach (var instruction in instructions) + { + if (instruction.Calls(dsp)) { instruction.operand = AccessTools.Method(typeof(ConductorPatch), nameof(DspTime)); replacements++; } + else if (instruction.Calls(time)) instruction.operand = AccessTools.Method(typeof(ConductorPatch), nameof(UnscaledTime)); + else if (instruction.Calls(delta)) instruction.operand = AccessTools.Method(typeof(ConductorPatch), nameof(UnscaledDelta)); + yield return instruction; + } + if (replacements == 0) throw new InvalidOperationException("Unsupported scrConductor.Update: DSP clock was not found."); + } + } + + [HarmonyPatch(typeof(scrConductor), "StartMusic")] + internal static class StartMusicPatch + { + static bool Prefix(scrConductor __instance, Action onSongScheduled) + { + if (!RendererController.ControlsTime) return true; + // The ordinary coroutine waits for AudioSource.isPlaying and invokes + // PostSong according to wall time. Silent rendering owns this lifetime. + var field = AccessTools.Field(typeof(scrConductor), "startMusicCoroutine"); + var old = field.GetValue(__instance) as Coroutine; + if (old != null) __instance.StopCoroutine(old); + __instance.dspTime = RendererController.Instance.Clock.DspTime; + __instance.dspTimeSong = __instance.dspTime + 1.0; + field.SetValue(__instance, __instance.StartCoroutine(Schedule(__instance, onSongScheduled))); + return false; + } + + static IEnumerator Schedule(scrConductor conductor, Action scheduled) + { + var renderer = RendererController.Instance; + // Allow Start_Rewind to finish its reset before notifying the controller. + yield return null; + if (!RendererController.ControlsTime) yield break; + // Use a fixed one-second preroll instead of the audio-device buffer. + // Autoplay is enabled after preparation, so keep normal countdown timing. + try { renderer.ScheduleAudio(conductor); scheduled?.Invoke(); renderer.MusicScheduled(); } + catch (Exception ex) { renderer.AbortWithError(ex); yield break; } + while (RendererController.ControlsTime && renderer.Clock.DspTime < conductor.dspTimeSong) yield return null; + if (RendererController.ControlsTime) conductor.hasSongStarted = true; + } + } + + [HarmonyPatch] + internal static class ConductorResetPatch + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(scrConductor), "Start"); + yield return AccessTools.Method(typeof(scrConductor), "Rewind"); + } + static void Postfix(scrConductor __instance) + { + if (!RendererController.ControlsTime) return; + __instance.dspTime = RendererController.Instance.Clock.DspTime; + __instance.dspTimeSong = __instance.dspTime + 1.0; + AccessTools.Field(typeof(scrConductor), "lastReportedPlayheadPosition").SetValue(__instance, __instance.dspTime - 1.0 / 60.0); + } + } + + [HarmonyPatch(typeof(scrConductor), "set_songposition_minusi")] + internal static class SongPositionPatch + { + static void Prefix(scrConductor __instance, ref double value) + { + if (!RendererController.ControlsTime || __instance.song == null) return; + // Avoid the float conversion in the stock Update, retaining double + // precision throughout long renders. minusv still applies game calibration. + value = RendererController.Instance.Clock.SongPosition(__instance.dspTimeSong, + __instance.song.pitch, __instance.addoffset, scrConductor.calibration_i); + } + } +} diff --git a/ADOFAIRenderer/Patches/GameplayPatch.cs b/ADOFAIRenderer/Patches/GameplayPatch.cs new file mode 100644 index 0000000..16876bb --- /dev/null +++ b/ADOFAIRenderer/Patches/GameplayPatch.cs @@ -0,0 +1,191 @@ +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using ADOFAIRenderer.Renderer; + +namespace ADOFAIRenderer.Patches +{ + [HarmonyPatch(typeof(scrHitTextManager), nameof(scrHitTextManager.ShowHitText), typeof(HitMargin), typeof(scrPlanet), typeof(float))] + internal static class HideJudgmentsPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(scnLevelSelect), "CheckAudioBreak")] + internal static class AudioDevicePatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + // Select the game's synchronous autoplay path, never synthesize input ticks. + [HarmonyPatch(typeof(AsyncInputManager), "get_isActive")] + internal static class SynchronousGameplayPatch + { + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + [HarmonyPatch(typeof(scrController), "UpdateInput")] + internal static class GameplayInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + // The editor and a few menu components read these directly instead of + // going through scrController.UpdateInput. Return an empty input state so + // keyboard, controller, and back/menu actions cannot modify the level + // while the render clock owns the frame. + [HarmonyPatch(typeof(RDInput), "GetMain")] + internal static class RendererMainInputPatch + { + static bool Prefix(ref int __result) + { + if (!RendererController.ControlsTime) return true; + __result = 0; + return false; + } + } + + [HarmonyPatch] + internal static class RendererMainKeyListPatch + { + static IEnumerable TargetMethods() + { + yield return AccessTools.Method(typeof(RDInput), "GetMainPressKeys"); + yield return AccessTools.Method(typeof(RDInput), "GetMainHeldKeys"); + } + + static bool Prefix(ref List __result) + { + if (!RendererController.ControlsTime) return true; + __result = new List(); + return false; + } + } + + [HarmonyPatch] + internal static class RendererBackInputPatch + { + static IEnumerable TargetMethods() + { + yield return AccessTools.PropertyGetter(typeof(RDInput), "backPress"); + yield return AccessTools.PropertyGetter(typeof(RDInput), "backIsPressed"); + } + + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + // AsyncInput is used by editor shortcuts and some UI objects without + // passing through RDInput. Block all query overloads during rendering. + [HarmonyPatch] + internal static class RendererAsyncInputPatch + { + static IEnumerable TargetMethods() + { + foreach (var method in typeof(AsyncInput).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)) + if (method.Name == "GetKey" || method.Name == "GetKeyDown" || method.Name == "GetKeyUp") + yield return method; + } + + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + [HarmonyPatch] + internal static class RendererInputDevicePatch + { + static IEnumerable TargetMethods() + { + foreach (var type in new[] { + typeof(RDInputType_Keyboard), typeof(RDInputType_AsyncKeyboard), + typeof(RDInputType_Joystick), typeof(RDInputType_Mouse) }) + { + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static)) + if (method.ReturnType == typeof(bool) && (method.Name == "CheckKeyState" || method.Name == "Back")) + yield return method; + } + } + + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + [HarmonyPatch(typeof(scrController), "ProcessKeyInputs")] + internal static class ProcessKeyInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(scrController), "DebugUpdate")] + internal static class DebugInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(scnEditor), "HandleKeyboardActions")] + internal static class EditorKeyboardInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(scnEditor), "TryQuitToMenu")] + internal static class EditorQuitInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(RDEditorUtils), "CheckForKeyCombo")] + internal static class EditorKeyComboPatch + { + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + [HarmonyPatch(typeof(scrTempEscToQuit), "Update")] + internal static class TemporaryEscapeInputPatch + { + static bool Prefix() => !RendererController.ControlsTime; + } + + [HarmonyPatch(typeof(scrPlayerManager), "AnyValidInputWasTriggered")] + internal static class StartAndExitInputPatch + { + static bool Prefix(ref bool __result) + { + if (!RendererController.ControlsTime) return true; + __result = false; + return false; + } + } + + [HarmonyPatch(typeof(scrController), "TogglePauseGame")] + internal static class RenderPausePatch + { + static bool Prefix(scrController __instance, ref bool __result) + { + if (RendererController.Instance == null || RendererController.Instance.State != RenderState.Rendering) return true; + __result = __instance.paused; + return false; + } + } +} diff --git a/ADOFAIRenderer/Patches/TweenClockPatch.cs b/ADOFAIRenderer/Patches/TweenClockPatch.cs new file mode 100644 index 0000000..fc07b56 --- /dev/null +++ b/ADOFAIRenderer/Patches/TweenClockPatch.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Reflection.Emit; +using DG.Tweening.Core; +using HarmonyLib; +using ADOFAIRenderer.Renderer; + +namespace ADOFAIRenderer.Patches +{ + // DOTween's independent tweens measure realtimeSinceStartup, even with + // captureFramerate enabled. Keep the original timestamp bookkeeping, but + // replace its elapsed interval so UI/independent effects cannot race encoding. + [HarmonyPatch(typeof(DOTweenComponent), "Update")] + internal static class TweenClockPatch + { + static float Delta(float actual) => RendererController.ControlsTime ? 1f / RendererController.Instance.Clock.Fps : actual; + static IEnumerable Transpiler(IEnumerable instructions) + { + var field = AccessTools.Field(typeof(DOTweenComponent), "_unscaledDeltaTime"); + int count = 0; + foreach (var instruction in instructions) + { + if (instruction.opcode == OpCodes.Stfld && Equals(instruction.operand, field)) + { + var adjustment = new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(TweenClockPatch), nameof(Delta))); + adjustment.labels.AddRange(instruction.labels); instruction.labels.Clear(); + yield return adjustment; + count++; + } + yield return instruction; + } + if (count == 0) throw new InvalidOperationException("Unsupported DOTween independent clock."); + } + } +} diff --git a/ADOFAIRenderer/Properties/AssemblyInfo.cs b/ADOFAIRenderer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..82831ed --- /dev/null +++ b/ADOFAIRenderer/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("ADOFAIRenderer")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ADOFAIRenderer")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("6D295B50-05F2-474C-BC70-B38B0FC0A3FF")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] \ No newline at end of file diff --git a/ADOFAIRenderer/Renderer/FFmpegEncoder.cs b/ADOFAIRenderer/Renderer/FFmpegEncoder.cs new file mode 100644 index 0000000..226315d --- /dev/null +++ b/ADOFAIRenderer/Renderer/FFmpegEncoder.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; + +namespace ADOFAIRenderer.Renderer +{ + internal sealed class FFmpegEncoder : IDisposable + { + internal sealed class Frame + { + public readonly byte[] Bytes; + public long Index; + + public Frame(int byteCount) { Bytes = new byte[byteCount]; } + } + private readonly BlockingCollection free; + private readonly BlockingCollection work; + private readonly StringBuilder stderr = new StringBuilder(); + private readonly Process process; + private readonly Thread writer; + private volatile Exception failure; + private bool disposed; + public long WrittenFrames => Interlocked.Read(ref written); + private long written; + + // Kept for the standalone encoder tests and for callers that use the + // original API. RendererController uses the configurable overload. + public FFmpegEncoder(string executable, string output) + : this(executable, output, 1920, 1080, 60, 18, "veryfast", true, true) { } + + public FFmpegEncoder(string executable, string output, int width, int height, int fps, int bitrateMbps, string preset) + : this(executable, output, width, height, fps, bitrateMbps, preset, false, true) { } + + public FFmpegEncoder(string executable, string output, int width, int height, int fps, int bitrateMbps, + string preset, bool fastStart) + : this(executable, output, width, height, fps, bitrateMbps, preset, false, fastStart) { } + + private FFmpegEncoder(string executable, string output, int width, int height, int fps, int bitrateMbps, + string preset, bool legacyCrf, bool fastStart) + { + if (!File.Exists(executable)) throw new FileNotFoundException("FFmpeg executable not found", executable); + if (width <= 0 || height <= 0 || (width & 1) != 0 || (height & 1) != 0 || fps <= 0 || bitrateMbps <= 0) + throw new ArgumentOutOfRangeException(); + if (string.IsNullOrEmpty(preset)) preset = "fast"; + var frameByteCount = checked(width * height * 4); + // Readback and encoding share this pool. Four buffers leave almost + // no overlap once two or three GPU requests are in flight, so use + // the available 128 MiB pipeline budget without letting 4K buffers + // grow memory usage unexpectedly. + var bufferCount = (int)Math.Max(4L, Math.Min(8L, (128L * 1024 * 1024) / frameByteCount)); + free = new BlockingCollection(bufferCount); + work = new BlockingCollection(bufferCount); + var bufferSizeMbps = Math.Max(1, bitrateMbps * 2); + var rateControl = legacyCrf + ? "-crf 18" + : "-b:v " + bitrateMbps + "M -maxrate " + bitrateMbps + "M -bufsize " + bufferSizeMbps + "M"; + process = new Process { StartInfo = new ProcessStartInfo { + FileName = executable, + Arguments = "-hide_banner -loglevel warning -nostdin -n -f rawvideo -pixel_format rgba -video_size " + + width + "x" + height + " -framerate " + fps + " -i pipe:0 -an -vf vflip -c:v libx264 -preset " + + preset + " " + rateControl + " -pix_fmt yuv420p" + + (fastStart ? " -movflags +faststart" : "") + " \"" + output + "\"", + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardError = true + }}; + process.ErrorDataReceived += (sender, args) => { + if (args.Data == null) return; + lock (stderr) { + stderr.AppendLine(args.Data); + if (stderr.Length > 16384) stderr.Remove(0, stderr.Length - 16384); + } + }; + try + { + process.Start(); + process.BeginErrorReadLine(); + for (int i = 0; i < bufferCount; i++) free.Add(new Frame(frameByteCount)); + writer = new Thread(WriteFrames) { IsBackground = true, Name = "ADOFAI FFmpeg" }; + writer.Start(); + } + catch { AbortProcess(); process.Dispose(); throw; } + } + + private void WriteFrames() + { + try + { + var stream = process.StandardInput.BaseStream; + foreach (var frame in work.GetConsumingEnumerable()) + { + if (frame.Index != WrittenFrames) throw new InvalidDataException("Frame ordering violation."); + stream.Write(frame.Bytes, 0, frame.Bytes.Length); + Interlocked.Increment(ref written); + free.Add(frame); + } + stream.Flush(); + stream.Close(); + } + catch (Exception ex) { failure = ex; } + } + + public void Check() + { + if (failure != null) throw new IOException("FFmpeg input failed: " + ErrorText(), failure); + if (process.HasExited) throw new IOException("FFmpeg exited (" + process.ExitCode + "): " + ErrorText()); + } + private string ErrorText() { lock (stderr) return stderr.ToString(); } + public bool TryRent(out Frame frame) { Check(); return free.TryTake(out frame); } + public Frame Rent() + { + var timeout = Stopwatch.StartNew(); + while (true) + { + Check(); + if (free.TryTake(out var frame, 100)) return frame; + if (timeout.Elapsed.TotalSeconds > 30) throw new TimeoutException("FFmpeg stopped consuming frames."); + } + } + public void Submit(Frame frame) { Check(); work.Add(frame); } + public void Finish(long expectedFrames) + { + work.CompleteAdding(); + if (!writer.Join(30000)) { AbortProcess(); throw new TimeoutException("FFmpeg input did not finish."); } + if (failure != null) throw new IOException("FFmpeg input failed: " + ErrorText(), failure); + if (!process.WaitForExit(30000)) { AbortProcess(); throw new TimeoutException("FFmpeg did not finalize the MP4."); } + process.WaitForExit(); // Drain asynchronous stderr events after process exit. + if (process.ExitCode != 0) throw new IOException("FFmpeg exited (" + process.ExitCode + "): " + ErrorText()); + if (WrittenFrames != expectedFrames) throw new IOException("Encoded frame count does not match the render clock."); + } + private void AbortProcess() { try { if (!process.HasExited) process.Kill(); } catch (InvalidOperationException) { } } + public static void MuxAudio(string executable, string video, string audio, string output) + { + using (var mux = new Process { StartInfo = new ProcessStartInfo { + FileName = executable, UseShellExecute = false, CreateNoWindow = true, + RedirectStandardError = true, + Arguments = "-hide_banner -loglevel error -nostdin -n -i \"" + video + "\" -i \"" + audio + + "\" -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 320k -movflags +faststart \"" + output + "\"" + }}) + { + mux.Start(); + var errors = mux.StandardError.ReadToEndAsync(); + if (!mux.WaitForExit(60000)) { mux.Kill(); mux.WaitForExit(); throw new TimeoutException("Audio/video mux timed out."); } + if (mux.ExitCode != 0) throw new IOException("Audio/video mux failed: " + errors.GetAwaiter().GetResult()); + } + } + public void Dispose() + { + if (disposed) return; + disposed = true; + work.CompleteAdding(); + AbortProcess(); + if (writer != null && !writer.Join(5000)) return; // Do not dispose collections still used by a worker. + process.Dispose(); work.Dispose(); free.Dispose(); + } + } +} diff --git a/ADOFAIRenderer/Renderer/FrameCapture.cs b/ADOFAIRenderer/Renderer/FrameCapture.cs new file mode 100644 index 0000000..194f138 --- /dev/null +++ b/ADOFAIRenderer/Renderer/FrameCapture.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; + +namespace ADOFAIRenderer.Renderer +{ + internal sealed class FrameCapture : IDisposable + { + private sealed class CameraState + { + public Camera Camera; + public RenderTexture Target; + public float Aspect; + public bool Enabled; + public bool Orthographic; + public float OrthographicSize; + public Vector3 Position; + public Quaternion Rotation; + } + private sealed class CanvasState + { + public Canvas Canvas; + public bool Enabled; + } + private sealed class Pending + { + public FFmpegEncoder.Frame Frame; + public AsyncGPUReadbackRequest Request; + public bool Ready; + public Exception Error; + public void Complete(AsyncGPUReadbackRequest request) + { + if (Ready) return; + try + { + if (request.hasError) throw new InvalidOperationException("GPU readback failed at frame " + Frame.Index); + var data = request.GetData(); + if (data.Length != Frame.Bytes.Length) throw new InvalidOperationException("Unexpected GPU frame size."); + data.CopyTo(Frame.Bytes); + } + catch (Exception ex) { Error = ex; } + finally { Ready = true; } + } + } + private readonly List cameras = new List(); + private readonly List canvases = new List(); + private readonly Queue pending = new Queue(); + private readonly FFmpegEncoder encoder; + private readonly RenderTexture target; + private readonly int width; + private readonly int height; + private readonly scrCamera gameCamera; + private readonly float originalZoomSize; + private readonly Vector2 originalOffset; + private readonly int originalPositionStateInt; + private readonly PositionState originalPositionState; + private readonly bool overlayActive, quadActive; + private readonly int mainMask; + private Texture2D fallback; + private bool disposed; + public double BackpressureSeconds { get; private set; } + + public FrameCapture(FFmpegEncoder encoder, int width, int height) + { + this.encoder = encoder; + this.width = width; + this.height = height; + gameCamera = scrCamera.instance; + if (gameCamera == null || gameCamera.Bgcamstatic == null || gameCamera.BGcam == null || gameCamera.camobj == null) + throw new InvalidOperationException("ADOFAI camera chain is not available."); + originalZoomSize = gameCamera.zoomSize; + originalOffset = gameCamera.offset; + originalPositionStateInt = gameCamera.positionStateInt; + originalPositionState = gameCamera.positionState; + overlayActive = gameCamera.Overlaycam != null && gameCamera.Overlaycam.gameObject.activeSelf; + quadActive = gameCamera.quad != null && gameCamera.quad.activeSelf; + mainMask = gameCamera.camobj.cullingMask; + target = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32) { + name = "ADOFAI Frame", antiAliasing = 1, useMipMap = false, autoGenerateMips = false + }; + try + { + if (!target.Create()) throw new InvalidOperationException("Cannot allocate render target."); + var previous = RenderTexture.active; + try { RenderTexture.active = target; GL.Clear(true, true, Color.black); } + finally { RenderTexture.active = previous; } + Add(gameCamera.Bgcamstatic); Add(gameCamera.BGcam); Add(gameCamera.camobj); + // Overlaycam presents the already composited RT on a quad. Capturing + // it again would feed our own output back into itself. + if (gameCamera.Overlaycam != null) gameCamera.Overlaycam.gameObject.SetActive(false); + if (gameCamera.quad != null) gameCamera.quad.SetActive(false); + foreach (var canvas in UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None)) + { + // Keep world-space level decorations; exclude editor/game HUD + // and third-party screen-space overlays from the three cameras. + if (!canvas.isRootCanvas || canvas.renderMode == RenderMode.WorldSpace) continue; + canvases.Add(new CanvasState { Canvas = canvas, Enabled = canvas.enabled }); + canvas.enabled = false; + } + if (!SystemInfo.supportsAsyncGPUReadback) + fallback = new Texture2D(width, height, TextureFormat.RGBA32, false); + Bind(); + } + catch { Dispose(); throw; } + } + private void Add(Camera camera) + { + if (cameras.Exists(s => s.Camera == camera)) return; + cameras.Add(new CameraState { + Camera = camera, + Target = camera.targetTexture, + Aspect = camera.aspect, + Enabled = camera.enabled, + Orthographic = camera.orthographic, + OrthographicSize = camera.orthographicSize, + Position = camera.transform.position, + Rotation = camera.transform.rotation + }); + } + public void Bind() + { + if (gameCamera.Overlaycam != null) gameCamera.Overlaycam.gameObject.SetActive(false); + if (gameCamera.quad != null) gameCamera.quad.SetActive(false); + foreach (var state in canvases) if (state.Canvas != null) state.Canvas.enabled = false; + foreach (var state in cameras) + { + if (state.Camera == null) throw new InvalidOperationException("A render camera was destroyed."); + // Own the camera output for the duration of the render. The old + // path let Unity draw these cameras to the game window and then + // called Camera.Render again into this texture, doubling the + // scene-rendering work for every encoded frame. RendererController + // calls Bind from its last LateUpdate, immediately before Unity's + // normal camera pass, so that pass can be captured directly. + state.Camera.targetTexture = target; + state.Camera.aspect = width / (float)height; + // Camera.Render ignored the component's enabled flag on the old + // manual path; keep that behavior while using the automatic pass. + state.Camera.enabled = true; + } + } + public void Capture(long index) + { + Drain(false); + if (!encoder.TryRent(out var buffer)) + { + long waitStart = System.Diagnostics.Stopwatch.GetTimestamp(); + // Never yield a Unity frame under backpressure: that would advance + // tweens/particles while the song clock and output frame stand still. + Drain(true); + buffer = encoder.Rent(); + BackpressureSeconds += (System.Diagnostics.Stopwatch.GetTimestamp() - waitStart) + / (double)System.Diagnostics.Stopwatch.Frequency; + } + buffer.Index = index; + if (fallback != null) + { + var previous = RenderTexture.active; + try + { + RenderTexture.active = target; + fallback.ReadPixels(new Rect(0, 0, width, height), 0, 0, false); + fallback.GetRawTextureData().CopyTo(buffer.Bytes); + } + finally { RenderTexture.active = previous; } + encoder.Submit(buffer); + return; + } + var frame = new Pending { Frame = buffer }; + // Copy in the callback; Unity request data is only valid for one frame. + frame.Request = AsyncGPUReadback.Request(target, 0, TextureFormat.RGBA32, frame.Complete); + pending.Enqueue(frame); + } + public void Drain(bool wait) + { + while (pending.Count > 0) + { + var frame = pending.Peek(); + if (!frame.Ready && wait) + { + frame.Request.WaitForCompletion(); + frame.Complete(frame.Request); + } + if (!frame.Ready) break; + if (frame.Error != null) throw new InvalidOperationException("Capture failed.", frame.Error); + encoder.Submit(frame.Frame); + pending.Dequeue(); + } + } + public void Dispose() + { + if (disposed) return; + disposed = true; + // Readbacks must release the texture before it can be destroyed, even + // when encoder failure/cancellation means their frames are discarded. + foreach (var frame in pending) if (!frame.Ready) frame.Request.WaitForCompletion(); + pending.Clear(); + foreach (var state in cameras) if (state.Camera != null) { + state.Camera.targetTexture = state.Target; + state.Camera.aspect = state.Aspect; + state.Camera.enabled = state.Enabled; + state.Camera.orthographic = state.Orthographic; + state.Camera.orthographicSize = state.OrthographicSize; + state.Camera.transform.SetPositionAndRotation(state.Position, state.Rotation); + } + // PrepareRenderCamera changes scrCamera's source state as well as + // the Camera components. Restore both so the editor resumes with + // exactly the same view after rendering. + if (gameCamera != null) { + gameCamera.zoomSize = originalZoomSize; + gameCamera.offset = originalOffset; + gameCamera.positionStateInt = originalPositionStateInt; + gameCamera.positionState = originalPositionState; + } + foreach (var state in canvases) if (state.Canvas != null) { + state.Canvas.enabled = state.Enabled; + } + if (gameCamera != null) { + if (gameCamera.camobj != null) gameCamera.camobj.cullingMask = mainMask; + if (gameCamera.Overlaycam != null) gameCamera.Overlaycam.gameObject.SetActive(overlayActive); + if (gameCamera.quad != null) gameCamera.quad.SetActive(quadActive); + } + if (fallback != null) UnityEngine.Object.Destroy(fallback); + if (target != null) { target.Release(); UnityEngine.Object.Destroy(target); } + } + } +} diff --git a/ADOFAIRenderer/Renderer/GameAudioCapture.cs b/ADOFAIRenderer/Renderer/GameAudioCapture.cs new file mode 100644 index 0000000..49273a8 --- /dev/null +++ b/ADOFAIRenderer/Renderer/GameAudioCapture.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Text; +using Unity.Collections; +using UnityEngine; + +namespace ADOFAIRenderer.Renderer +{ + internal sealed class GameAudioCapture : IDisposable + { + private FileStream stream; + private NativeArray samples; + private float[] managed; + private byte[] bytes; + private bool started; + public int SampleRate { get; private set; } + public int Channels { get; private set; } + public long SampleFrames { get; private set; } + public float Peak { get; private set; } + + public void Begin(string path) + { + SampleRate = AudioSettings.outputSampleRate; + switch (AudioSettings.speakerMode) + { + case AudioSpeakerMode.Mono: Channels = 1; break; + case AudioSpeakerMode.Stereo: case AudioSpeakerMode.Prologic: Channels = 2; break; + case AudioSpeakerMode.Quad: Channels = 4; break; + case AudioSpeakerMode.Surround: Channels = 5; break; + case AudioSpeakerMode.Mode5point1: Channels = 6; break; + case AudioSpeakerMode.Mode7point1: Channels = 8; break; + default: throw new InvalidOperationException("Unsupported audio speaker layout."); + } + if (SampleRate <= 0) throw new InvalidOperationException("Audio device has no sample rate."); + try + { + stream = new FileStream(path, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read); + WriteHeader(0); + if (!AudioRenderer.Start()) throw new InvalidOperationException("Unity AudioRenderer could not start game audio capture."); + started = true; + } + catch { Dispose(); throw; } + } + + public void CaptureFrame() + { + if (!started) throw new InvalidOperationException("Audio was not initialized before capturing frame zero."); + int count = AudioRenderer.GetSampleCountForCaptureFrame(); + if (count <= 0) throw new InvalidOperationException("Unity AudioRenderer returned no samples. Game audio capture is unavailable; disable Audio to render video only."); + int length = checked(count * Channels); + if (!samples.IsCreated || samples.Length != length) + { + if (samples.IsCreated) samples.Dispose(); + samples = new NativeArray(length, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); + managed = new float[length]; bytes = new byte[length * sizeof(float)]; + } + if (!AudioRenderer.Render(samples)) throw new InvalidOperationException("Unity AudioRenderer failed to render the audio frame."); + samples.CopyTo(managed); + for (int i = 0; i < managed.Length; i++) Peak = Math.Max(Peak, Math.Abs(managed[i])); + Buffer.BlockCopy(managed, 0, bytes, 0, bytes.Length); + stream.Write(bytes, 0, bytes.Length); + SampleFrames += count; + if (stream.Length > uint.MaxValue - 36L) throw new IOException("WAV exceeded its 4 GB size limit."); + } + + public void Complete(long videoFrames, int fps) + { + long targetSamples = checked(videoFrames * (long)SampleRate / fps); + // Unity's audio mixer can round the final block. Correct only that + // boundary; reject drift instead of silently stretching the song. + int tolerance = Math.Max(AudioSettings.GetConfiguration().dspBufferSize * 2, SampleRate / fps * 2); + if (Math.Abs(SampleFrames - targetSamples) > tolerance) + throw new InvalidOperationException("Audio drift: captured " + SampleFrames + " sample frames, expected " + targetSamples + "."); + long dataLength = checked(targetSamples * Channels * sizeof(float)); + stream.SetLength(44 + dataLength); + stream.Position = 0; WriteHeader(dataLength); stream.Flush(); + stream.Dispose(); stream = null; + } + private void WriteHeader(long size) + { + using (var writer = new BinaryWriter(stream, Encoding.ASCII, true)) + { + writer.Write(Encoding.ASCII.GetBytes("RIFF")); writer.Write(checked((uint)(36 + size))); + writer.Write(Encoding.ASCII.GetBytes("WAVEfmt ")); writer.Write(16u); + writer.Write((ushort)3); writer.Write((ushort)Channels); writer.Write(SampleRate); + writer.Write(SampleRate * Channels * 4); writer.Write((ushort)(Channels * 4)); writer.Write((ushort)32); + writer.Write(Encoding.ASCII.GetBytes("data")); writer.Write(checked((uint)size)); + } + } + public void Dispose() + { + try { if (started) AudioRenderer.Stop(); } + finally + { + started = false; + if (samples.IsCreated) samples.Dispose(); + stream?.Dispose(); stream = null; + } + } + } +} diff --git a/ADOFAIRenderer/Renderer/RenderClock.cs b/ADOFAIRenderer/Renderer/RenderClock.cs new file mode 100644 index 0000000..dfa8a42 --- /dev/null +++ b/ADOFAIRenderer/Renderer/RenderClock.cs @@ -0,0 +1,28 @@ +using System; + +namespace ADOFAIRenderer.Renderer +{ + public sealed class RenderClock + { + public int Fps { get; } + public long FrameIndex { get; private set; } + public double Time => FrameIndex / (double)Fps; + // A fixed DSP origin also keeps scheduling independent of the audio device. + public double DspOrigin { get; private set; } = 1000.0; + public double DspTime => DspOrigin + Time; + public void AnchorDsp(double origin) + { + if (FrameIndex != 0 || double.IsNaN(origin) || double.IsInfinity(origin)) + throw new InvalidOperationException("DSP origin must be anchored before frame zero."); + DspOrigin = origin; + } + public RenderClock(int fps = 60) + { + if (fps <= 0) throw new ArgumentOutOfRangeException(nameof(fps)); + Fps = fps; + } + public double SongPosition(double scheduledStart, double pitch, double offset, double calibration) + => (DspTime - scheduledStart - calibration) * pitch - offset; + public void Advance() { checked { FrameIndex++; } } + } +} diff --git a/ADOFAIRenderer/Renderer/RendererController.cs b/ADOFAIRenderer/Renderer/RendererController.cs new file mode 100644 index 0000000..8aee404 --- /dev/null +++ b/ADOFAIRenderer/Renderer/RendererController.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using HarmonyLib; +using UnityEngine; + +namespace ADOFAIRenderer.Renderer +{ + public enum RenderState { Idle, Preparing, Rendering, Finishing, Completed, Failed, Cancelled } + + [DefaultExecutionOrder(32000)] + public sealed class RendererController : MonoBehaviour + { + private static readonly WaitForEndOfFrame EndOfFrame = new WaitForEndOfFrame(); + public static RendererController Instance { get; private set; } + public static bool ControlsTime => Instance != null && Instance.saved != null && + (Instance.State == RenderState.Preparing || Instance.State == RenderState.Rendering); + public RenderState State { get; private set; } + public RenderClock Clock { get; private set; } = new RenderClock(); + public string Message { get; private set; } = "Open a Custom Level, then Render."; + public string ToastText { get; private set; } = "Open a Custom Level, then press F6 to render."; + public string OutputPath { get; private set; } = ""; + public string FFmpegPath = ""; + private readonly System.Diagnostics.Stopwatch renderTimer = new System.Diagnostics.Stopwatch(); + public double GenerationFps => renderTimer.Elapsed.TotalSeconds > 0 ? CapturedFrames / renderTimer.Elapsed.TotalSeconds : 0; + public double ElapsedSeconds => renderTimer.Elapsed.TotalSeconds; + public double CaptureWaitSeconds { get; private set; } + public long TotalFrames { get; private set; } + public long CapturedFrames { get; private set; } + public bool Busy => State == RenderState.Preparing || State == RenderState.Rendering || State == RenderState.Finishing + || rpcLoadRoutine != null; + private FrameCapture capture; + private FFmpegEncoder encoder; + private SavedState saved; + private Coroutine routine; + private scnGame level; + private scnEditor editor; + private bool cancellation; + private string partialPath; + private string audioPath, muxPath; + private GameAudioCapture audio; + private float toastUntil; + private bool captureAudioForRun; + private RenderProfile profile; + private float escapeHeldAt = -1f; + private bool forceCancelTriggered; + private readonly ConcurrentQueue rpcCommands = new ConcurrentQueue(); + private Coroutine rpcLoadRoutine; + private RpcRenderJob activeRpcJob; + private double nextProgressUpdateAt; + + private void Awake() { Instance = this; FFmpegPath = Path.Combine(Main.Entry.Path, "ffmpeg.exe"); } + public void StartRender() + { + if (Busy || !Main.Enabled) return; + State = RenderState.Preparing; + cancellation = false; + CapturedFrames = TotalFrames = 0; + OutputPath = ""; partialPath = null; + audioPath = muxPath = null; + renderTimer.Reset(); + nextProgressUpdateAt = 0; + CaptureWaitSeconds = 0; + ClearQueuedInput(); + escapeHeldAt = -1f; + forceCancelTriggered = false; + var settings = Main.Settings ?? new RendererSettings(); + var options = activeRpcJob != null ? activeRpcJob.Options : null; + profile = settings.ResolveProfile(options?.Preset, options?.Width, options?.Height, + options?.Fps, options?.BitrateMbps, options?.EndDelaySeconds); + Clock = new RenderClock(profile.Fps); + Message = string.Format("Preparing {0}x{1} @ {2} fps ({3} Mbps)...", + profile.Width, profile.Height, profile.Fps, profile.BitrateMbps); + captureAudioForRun = activeRpcJob != null + ? activeRpcJob.CaptureAudio + : Main.Settings == null || Main.Settings.CaptureAudio; + activeRpcJob?.SetState(RpcJobState.Preparing); + ShowToast(Message, 4f); + routine = StartCoroutine(GuardedRun()); + } + public void Cancel() { if (Busy) cancellation = true; } + + internal void EnqueueRpcRender(RpcRenderRequest request) + { + if (request != null && request.Job != null) rpcCommands.Enqueue(request); + } + + internal void EnqueueRpcCancel(RpcCancelRequest request) + { + if (request != null && !string.IsNullOrEmpty(request.JobId)) rpcCommands.Enqueue(request); + } + + internal bool ToastVisible => Time.unscaledTime <= toastUntil; + + internal void ShowToast(string text, float seconds, bool useGameNotification = true) + { + ToastText = text ?? string.Empty; + toastUntil = Time.unscaledTime + Mathf.Max(0.5f, seconds); + if (!useGameNotification || ADOBase.editor == null) return; + try + { + // This is ADOFAI's own editor notification bar. The fallback + // OnGUI toast below remains visible while the render canvas is + // temporarily hidden from the captured camera. + ADOBase.editor.ShowNotification(ToastText, null, seconds); + } + catch (Exception ex) { Main.Entry.Logger.Log("Game notification unavailable: " + ex.Message); } + } + + private void ShowProgressToast() + { + if (TotalFrames <= 0) return; + ToastText = string.Format("Rendering {0:F1}% | {1} / {2} frames | {3:F1} fps", + 100.0 * CapturedFrames / TotalFrames, CapturedFrames, TotalFrames, GenerationFps); + toastUntil = Time.unscaledTime + 1.0f; + } + private IEnumerator GuardedRun() + { + var run = Run(); + try + { + while (!cancellation) + { + object next; + try { if (!run.MoveNext()) break; next = run.Current; } + catch (Exception ex) { Fail(ex); break; } + yield return next; + } + if (cancellation) { State = RenderState.Cancelled; Message = "Render cancelled."; ShowToast(Message, 5f); } + } + finally { (run as IDisposable)?.Dispose(); Cleanup(); routine = null; } + } + private IEnumerator Run() + { + // Start at a frame boundary; OnGUI can run several times per frame. + yield return EndOfFrame; + editor = ADOBase.editor; + level = editor != null ? editor.customLevel : ADOBase.customLevel; + ValidateLoadedLevel(); + if (GCS.d_oldConductor || GCS.d_webglConductor) + throw new InvalidOperationException("The installed conductor must use its standard DSP timing mode."); + var directory = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "Renders"); + Directory.CreateDirectory(directory); + var name = SanitizeName(ADOBase.controller.levelName); + OutputPath = Path.Combine(directory, name + "_" + DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss") + "_" + Guid.NewGuid().ToString("N").Substring(0, 6) + ".mp4"); + partialPath = Path.ChangeExtension(OutputPath, ".partial.mp4"); + audioPath = Path.ChangeExtension(OutputPath, ".partial.wav"); + muxPath = Path.ChangeExtension(OutputPath, ".mux.mp4"); + encoder = new FFmpegEncoder(FFmpegPath, partialPath, profile.Width, profile.Height, + profile.Fps, profile.BitrateMbps, profile.FfmpegPreset, !captureAudioForRun); + saved = new SavedState(); + if (editor != null) + { + Main.Entry.Logger.Log("Preparing editor render: playMode=" + editor.playMode + + ", strictlyEditing=" + editor.inStrictlyEditingMode + ", tiles=" + editor.floors.Count); + // playMode includes paused playback. The editor's initial setup + // does not initialize inStrictlyEditingMode, so that flag cannot + // tell whether a freshly opened editor is ready to render. + if (editor.playMode) editor.SwitchToEditMode(); + } + Time.captureFramerate = profile.Fps; + Time.timeScale = 1; + DG.Tweening.DOTween.useSmoothDeltaTime = false; + QualitySettings.vSyncCount = 0; + Application.targetFrameRate = -1; + Application.runInBackground = true; + if (!captureAudioForRun) AudioListener.volume = 0; + AudioListener.pause = false; + Persistence.skipIntroBehavior = SkipIntroBehavior.Off; + GCS.checkpointNum = 0; + RDC.auto = false; // Preserve the normal countdown, avoiding the editor's fast-takeoff shortcut. + RDC.noHud = true; + RDC.noAutoHud = true; + yield return null; + if (editor != null) + { + editor.SelectFloor(editor.floors[0], cameraJump: false); + editor.Play(); + } + else + { + level.ResetScene(); + if (!level.Play(0)) throw new InvalidOperationException("Custom Level playback could not start."); + // The official preparation coroutine warms filters over two frames. + int preparationFrames = 0; + while (level.isLoading) + { + if (++preparationFrames > 600) throw new TimeoutException("Custom Level preparation did not finish."); + yield return null; + } + AbortStartPrompt(); + ADOBase.conductor.Start(); + level.FinishCustomLevelLoading(0); + ADOBase.controller.Start_Rewind(0); + } + // editor.Play() leaves one frame of camera setup pending. Let that + // setup run before taking ownership of the gameplay cameras, then + // explicitly restore the normal gameplay framing below. + yield return null; + RDC.auto = true; + ADOBase.controller.noFail = true; + ADOBase.controller.paused = false; + ADOBase.controller.enabled = true; + Time.timeScale = 1; + ADOBase.conductor.dspTime = Clock.DspTime; + ADOBase.conductor.songposition_minusi = Clock.SongPosition(ADOBase.conductor.dspTimeSong, + ADOBase.conductor.song.pitch, ADOBase.conductor.addoffset, scrConductor.calibration_i); + capture = new FrameCapture(encoder, profile.Width, profile.Height); + PrepareRenderCamera(); + State = RenderState.Rendering; + renderTimer.Start(); + ApplyFramePacing(); + Message = string.Format("Rendering {0}x{1} @ {2} fps (hold Escape 1s to force-cancel)", + profile.Width, profile.Height, profile.Fps); + ShowToast(Message, 2f, false); + // Every output frame follows one complete game Update/LateUpdate/render. + while (true) + { + yield return null; + yield return EndOfFrame; + if (level == null || (editor != null ? editor.customLevel : ADOBase.customLevel) != level || ADOBase.controller == null || ADOBase.conductor == null) + throw new InvalidOperationException("The level was unloaded during rendering."); + encoder.Check(); + capture.Capture(Clock.FrameIndex); + CaptureWaitSeconds = capture.BackpressureSeconds; + if (captureAudioForRun) + { + if (audio == null) throw new InvalidOperationException("The game did not initialize game audio before frame zero."); + audio.CaptureFrame(); + } + CapturedFrames++; + // Throttle presentation work by wall time. At high offline + // generation rates, updating this every six output frames can + // format and rebuild the IMGUI text dozens of times per second. + var elapsed = renderTimer.Elapsed.TotalSeconds; + if (elapsed >= nextProgressUpdateAt) + { + ShowProgressToast(); + nextProgressUpdateAt = elapsed + 0.25; + } + if (TotalFrames > 0 && CapturedFrames >= TotalFrames) + { + var player = ADOBase.controller.playerOne; + var floors = ADOBase.lm.listFloors; + if (player == null || player.currFloor == null || player.currFloor.seqID < floors.Count - 1) + throw new InvalidOperationException("Autoplay did not reach the last tile at the expected end time."); + break; + } + if (TotalFrames == 0 && Clock.Time > 10) + throw new InvalidOperationException("The game did not schedule level playback."); + Clock.Advance(); + } + State = RenderState.Finishing; + renderTimer.Stop(); + Message = "Finalizing MP4..."; + ShowToast(Message, 8f, false); + capture.Drain(true); + // No Unity yields during finalization; gameplay must not progress further. + encoder.Finish(CapturedFrames); + if (audio != null) + { + audio.Complete(CapturedFrames, Clock.Fps); + audio.Dispose(); + FFmpegEncoder.MuxAudio(FFmpegPath, partialPath, audioPath, muxPath); + File.Move(muxPath, OutputPath); + File.Delete(partialPath); File.Delete(audioPath); + } + else File.Move(partialPath, OutputPath); + State = RenderState.Completed; + Message = "Completed: " + CapturedFrames + " frames." + + (audio != null && audio.Peak < 0.000001f ? " Audio mix was silent; check game sound settings." : ""); + ShowToast(Message, 8f); + Main.Entry.Logger.Log(string.Format("Completed: {0} frames in {1:F2}s, {2:F1} frames/s ({3:F2}x target). Video={4}x{5}@{6}fps {7}Mbps {8}. Audio={9}. Capture/encoder wait={10:F2}s. Output={11}", + CapturedFrames, ElapsedSeconds, GenerationFps, GenerationFps / Clock.Fps, + profile.Width, profile.Height, profile.Fps, profile.BitrateMbps, profile.FfmpegPreset, + audio != null, CaptureWaitSeconds, OutputPath)); + } + internal void ScheduleAudio(scrConductor conductor) + { + if (!captureAudioForRun) return; + audio = new GameAudioCapture(); + audio.Begin(audioPath); + // AudioRenderer controls the DSP timeline. Anchor once, then keep + // advancing video strictly by frame index; never read wall time to seek. + Clock.AnchorDsp(AudioSettings.dspTime); + conductor.dspTime = Clock.DspTime; + conductor.dspTimeSong = conductor.dspTime + 1.0; + double songStart = conductor.dspTimeSong + (conductor.separateCountdownTime + ? conductor.crotchetAtStart * conductor.adjustedCountdownTicks / conductor.song.pitch : 0.0); + foreach (var source in new[] { conductor.song, conductor.song2, conductor.song3 }) + { + if (source == null || source.clip == null) continue; + source.Stop(); source.time = 0; source.PlayScheduled(songStart); + } + conductor.PlayHitTimes(); + Main.Entry.Logger.Log("Game audio started: " + audio.SampleRate + " Hz, " + audio.Channels + " channels."); + } + internal void MusicScheduled() + { + var conductor = ADOBase.conductor; + double pitch = conductor.song.pitch; + if (pitch <= 0 || double.IsNaN(pitch) || double.IsInfinity(pitch)) + throw new InvalidOperationException("Invalid song pitch."); + var floors = ADOBase.lm.listFloors; + double last = floors[floors.Count - 1].entryTime; + double endDelay = profile != null ? profile.EndDelaySeconds : 2.0; + if (double.IsNaN(endDelay) || double.IsInfinity(endDelay) || endDelay < 0) endDelay = 2.0; + double end = conductor.dspTimeSong - Clock.DspOrigin + scrConductor.calibration_i + + (last + conductor.addoffset) / pitch + endDelay; + if (double.IsNaN(end) || double.IsInfinity(end) || end <= 0) + throw new InvalidOperationException("Invalid final tile time."); + TotalFrames = checked((long)Math.Ceiling(end * Clock.Fps) + 1); + Main.Entry.Logger.Log(string.Format("Render end delay: {0:F2}s, total frames: {1}", endDelay, TotalFrames)); + } + private void PrepareRenderCamera() + { + var camera = scrCamera.instance; + if (camera == null) return; + + var defaultZoom = scrCamera.DefaultCameraOrthoSize; + if (float.IsNaN(defaultZoom) || float.IsInfinity(defaultZoom) || defaultZoom <= 0f) + defaultZoom = camera.camobj != null && camera.camobj.orthographic + ? camera.camobj.orthographicSize : 5f; + + var controller = ADOBase.controller; + var player = controller != null ? controller.playerOne : null; + try + { + // This is the same camera path used by normal gameplay. A zero + // duration applies the player framing immediately, without + // adding a tween that could run ahead of the render clock. + if (player != null) + controller.MoveCameraToPlayer(0f, DG.Tweening.Ease.Linear, defaultZoom); + } + catch (Exception ex) + { + Main.Entry.Logger.Log("Gameplay camera refocus fallback: " + ex.Message); + } + + try + { + if (player != null) camera.Refocus(player.transform); + else if (level != null && level.levelMaker != null && editor != null && editor.floors != null && editor.floors.Count > 0) + camera.Refocus(editor.floors[0].transform); + } + catch (Exception ex) + { + Main.Entry.Logger.Log("Camera position refocus unavailable: " + ex.Message); + } + + // scrCamera reapplies zoomSize during its update, so set both its + // source value and every camera in the compositing chain. + camera.zoomSize = defaultZoom; + foreach (var outputCamera in new[] { camera.Bgcamstatic, camera.BGcam, camera.camobj }) + { + if (outputCamera != null && outputCamera.orthographic) + outputCamera.orthographicSize = defaultZoom; + } + Main.Entry.Logger.Log(string.Format("Prepared render camera: zoom={0:F3}, player={1}", + defaultZoom, player != null)); + } + private void ValidateLoadedLevel() + { + if (level == null) + throw new InvalidOperationException("No Custom Level instance is available. Open a level in the editor or Custom Level player."); + if (level.levelData == null) + throw new InvalidOperationException("The Custom Level has no loaded chart data."); + if (editor != null) + { + // scnEditor owns loading and the level maker while editing. + // scnGame.isLoading is cleared by the gameplay start coroutine; + // it can remain true for a fully loaded editor chart. + if (editor.isLoading) + throw new InvalidOperationException("The editor is still loading the chart."); + if (level.levelMaker == null || editor.floors == null || editor.floors.Count < 2) + throw new InvalidOperationException("The editor chart needs at least two tiles."); + } + else + { + if (level.isLoading) + throw new InvalidOperationException("Custom Level gameplay is still loading. Wait for the start prompt."); + if (ADOBase.lm == null || ADOBase.lm.listFloors == null || ADOBase.lm.listFloors.Count < 2) + throw new InvalidOperationException("Custom Level gameplay has no playable tile path."); + } + if (ADOBase.controller == null || ADOBase.conductor == null) + throw new InvalidOperationException("The gameplay controller or conductor is not ready."); + } + private static void AbortStartPrompt() + { + var controller = ADOBase.controller; + var field = AccessTools.Field(typeof(scrController), "waitForStartCoCallCount"); + field.SetValue(controller, (int)field.GetValue(controller) + 1); + scrUIController.instance.txtPressToStart.GetComponent().HideText(); + } + private void LateUpdate() + { + if (State != RenderState.Rendering) return; + try { ApplyFramePacing(); capture.Bind(); } + catch (Exception ex) { Fail(ex); StopAndClean(); } + } + private void Update() + { + ProcessRpcCommands(); + UpdateRpcJob(); + if (Busy) + { + UpdateForceCancelKey(); + return; + } + escapeHeldAt = -1f; + forceCancelTriggered = false; + if (Input.GetKeyDown(KeyCode.F6) && Main.Enabled && ADOBase.editor != null) + StartRender(); + } + + private void UpdateForceCancelKey() + { + if (!Input.GetKey(KeyCode.Escape)) + { + escapeHeldAt = -1f; + forceCancelTriggered = false; + return; + } + if (escapeHeldAt < 0f) escapeHeldAt = Time.unscaledTime; + if (!forceCancelTriggered && Time.unscaledTime - escapeHeldAt >= 1f) + { + forceCancelTriggered = true; + ForceCancel(); + } + } + + private void ForceCancel() + { + cancellation = true; + if (rpcLoadRoutine != null) { StopCoroutine(rpcLoadRoutine); rpcLoadRoutine = null; } + if (routine != null) { StopCoroutine(routine); routine = null; } + State = RenderState.Cancelled; + Message = "Render force-cancelled."; + ShowToast(Message, 5f); + activeRpcJob?.Cancel(); + Cleanup(); + activeRpcJob = null; + } + private void OnGUI() + { + if (!Main.Enabled) return; + // Rendering temporarily owns the gameplay cameras and editor + // overlays. Cover the presentation surface so a camera or canvas + // target change can never flash through to the player window. + if (Busy && Event.current.type == EventType.Repaint) + GUI.DrawTexture(new Rect(0f, 0f, Screen.width, Screen.height), + Texture2D.blackTexture, ScaleMode.StretchToFill, false); + if (!ToastVisible) return; + ADOFAIRenderer.UI.RendererWindow.DrawToast(this); + } + private void ProcessRpcCommands() + { + while (rpcCommands.TryDequeue(out var command)) + { + var render = command as RpcRenderRequest; + if (render != null) + { + if (activeRpcJob != null || Busy) + { + render.Job.Fail("A render is already in progress."); + continue; + } + activeRpcJob = render.Job; + cancellation = false; + activeRpcJob.SetState(RpcJobState.Loading); + rpcLoadRoutine = StartCoroutine(LoadRpcLevelAndStart(render.Job)); + continue; + } + + var cancel = command as RpcCancelRequest; + if (cancel != null && activeRpcJob != null && + string.Equals(activeRpcJob.Id, cancel.JobId, StringComparison.OrdinalIgnoreCase)) + { + Cancel(); + } + } + } + + private IEnumerator LoadRpcLevelAndStart(RpcRenderJob job) + { + var core = LoadRpcLevelAndStartCore(job); + while (true) + { + object next = null; + bool hasNext; + Exception failure = null; + try + { + hasNext = core.MoveNext(); + if (hasNext) next = core.Current; + } + catch (Exception ex) + { + hasNext = false; + failure = ex; + } + if (failure != null) + { + HandleRpcPreparationFailure(job, failure); + yield break; + } + if (!hasNext) yield break; + yield return next; + } + } + + private IEnumerator LoadRpcLevelAndStartCore(RpcRenderJob job) + { + if (!File.Exists(job.LevelPath)) + throw new FileNotFoundException("Level file does not exist.", job.LevelPath); + + var waitFrames = 0; + var editorDeadline = Time.realtimeSinceStartup + 60f; + var sceneRequested = false; + Main.Entry.Logger.Log("RPC preparing level: " + job.LevelPath + ", loader=" + (scrLoader.instance != null)); + while (ADOBase.editor == null || (sceneRequested && !ADOBase.isLevelEditor)) + { + if (cancellation) throw new OperationCanceledException(); + if (!sceneRequested && (waitFrames == 0 || waitFrames % 30 == 0) + && (scrLoader.instance != null || ADOBase.loader != null || ADOBase.controller != null || ADOBase.customLevel != null)) + { + try + { + OpenLevelEditorScene(); + sceneRequested = true; + Main.Entry.Logger.Log("RPC requested scnEditor scene."); + } + catch (Exception ex) + { + Main.Entry.Logger.Log("Waiting for the game loader before opening the editor: " + ex.Message); + } + } + waitFrames++; + if (waitFrames > 36000 || Time.realtimeSinceStartup > editorDeadline) + throw new TimeoutException("The level editor did not become available within 60 seconds."); + yield return null; + } + + var targetEditor = ADOBase.editor; + if (targetEditor.playMode) targetEditor.SwitchToEditMode(); + var previousLevel = targetEditor.customLevel; + targetEditor.OpenLevel(job.LevelPath); + Main.Entry.Logger.Log("RPC dispatched editor.OpenLevel: customLevel=" + (previousLevel != null) + + ", isLoading=" + targetEditor.isLoading); + + var sawLoading = false; + var loaded = false; + var loadDeadline = Time.realtimeSinceStartup + 120f; + for (var frame = 0; frame < 72000 && Time.realtimeSinceStartup <= loadDeadline; frame++) + { + if (cancellation) throw new OperationCanceledException(); + yield return null; + targetEditor = ADOBase.editor; + if (targetEditor == null) continue; + if (targetEditor.isLoading) sawLoading = true; + var loadedLevel = targetEditor.customLevel; + if (!targetEditor.isLoading && loadedLevel != null && loadedLevel.levelData != null + && targetEditor.floors != null && targetEditor.floors.Count > 1 + && (sawLoading || loadedLevel != previousLevel + || (frame >= 5 && PathsEqual(loadedLevel.levelPath, job.LevelPath)))) + { + loaded = true; + break; + } + } + if (!loaded) + { + var finalEditor = ADOBase.editor; + var finalLevel = finalEditor != null ? finalEditor.customLevel : null; + Main.Entry.Logger.Log("RPC level load state: editor=" + (finalEditor != null) + + ", isLoading=" + (finalEditor != null && finalEditor.isLoading) + + ", levelData=" + (finalLevel != null && finalLevel.levelData != null) + + ", floors=" + (finalEditor != null && finalEditor.floors != null ? finalEditor.floors.Count.ToString() : "null") + + ", previousSame=" + (finalLevel == previousLevel) + + ", path=" + (finalLevel != null ? finalLevel.levelPath : "null")); + throw new TimeoutException("The requested level did not finish loading in the editor."); + } + if (cancellation) throw new OperationCanceledException(); + + job.SetState(RpcJobState.Preparing); + rpcLoadRoutine = null; + StartRender(); + } + + private static void OpenLevelEditorScene() + { + if (scrLoader.instance != null) scrLoader.instance.GoToLevelEditor(); + else if (ADOBase.loader != null) ADOBase.loader.GoToLevelEditor(); + else if (ADOBase.controller != null) ADOBase.controller.GoToLevelEditor(); + else if (ADOBase.customLevel != null) ADOBase.customLevel.GoToLevelEditor(); + else throw new InvalidOperationException("The game loader is not ready."); + } + + private static bool PathsEqual(string left, string right) + { + if (string.IsNullOrEmpty(left) || string.IsNullOrEmpty(right)) return false; + try { return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); } + catch { return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); } + } + + private void HandleRpcPreparationFailure(RpcRenderJob job, Exception ex) + { + rpcLoadRoutine = null; + if (ex is OperationCanceledException) + { + State = RenderState.Cancelled; + Message = "Render cancelled."; + job.Cancel(); + } + else + { + State = RenderState.Failed; + Message = ex.Message; + job.Fail(ex.Message); + ShowToast("Render failed: " + Message, 10f); + Main.Entry.Logger.Error("RPC render preparation: " + ex); + } + activeRpcJob = null; + } + + private void UpdateRpcJob() + { + var job = activeRpcJob; + if (job == null) return; + job.SetProgress(CapturedFrames, TotalFrames, OutputPath); + if (rpcLoadRoutine != null) job.SetState(RpcJobState.Loading); + else if (State == RenderState.Preparing) job.SetState(RpcJobState.Preparing); + else if (State == RenderState.Rendering) job.SetState(RpcJobState.Rendering); + else if (State == RenderState.Finishing) job.SetState(RpcJobState.Finishing); + else if (State == RenderState.Completed) + { + job.SetState(RpcJobState.Completed); + job.SetProgress(CapturedFrames, TotalFrames, OutputPath); + } + else if (State == RenderState.Cancelled) job.Cancel(); + else if (State == RenderState.Failed) job.Fail(Message); + + if (rpcLoadRoutine == null && routine == null && !Busy && + (State == RenderState.Completed || State == RenderState.Cancelled || State == RenderState.Failed)) + activeRpcJob = null; + } + + private static void ApplyFramePacing() + { + // Game settings or other mods may restore a cap after editor.Play. + if (QualitySettings.vSyncCount != 0) QualitySettings.vSyncCount = 0; + if (Application.targetFrameRate != -1) Application.targetFrameRate = -1; + if (UnityEngine.Rendering.OnDemandRendering.renderFrameInterval != 1) + UnityEngine.Rendering.OnDemandRendering.renderFrameInterval = 1; + } + private void Fail(Exception ex) + { + State = RenderState.Failed; + Message = ex.Message; + ShowToast("Render failed: " + Message, 10f); + Main.Entry.Logger.Error(ex.ToString()); + } + internal void AbortWithError(Exception ex) { Fail(ex); StopAndClean(); } + public void StopAndClean() + { + if (rpcLoadRoutine != null) { StopCoroutine(rpcLoadRoutine); rpcLoadRoutine = null; } + if (routine != null) { StopCoroutine(routine); routine = null; } + if (Busy) { State = RenderState.Cancelled; Message = "Render cancelled."; } + Cleanup(); + } + private void Cleanup() + { + // Clear patch ownership before calling any normal game reset methods. + var restore = saved; + saved = null; + renderTimer.Stop(); + TryCleanup(() => capture?.Dispose()); capture = null; + TryCleanup(() => encoder?.Dispose()); encoder = null; + TryCleanup(() => audio?.Dispose()); audio = null; + if (restore != null) + { + // Reset playback with the user's autoplay setting, otherwise Play + // would retain renderer fast-takeoff flags in the restored session. + TryCleanup(restore.RestoreTiming); + TryCleanup(() => { + var conductor = ADOBase.conductor; + if (conductor != null) { + var handle = AccessTools.Field(typeof(scrConductor), "startMusicCoroutine").GetValue(conductor) as Coroutine; + if (handle != null) conductor.StopCoroutine(handle); + conductor.Rewind(); + conductor.song?.Stop(); conductor.song2?.Stop(); conductor.song3?.Stop(); + } + if (editor != null) editor.SwitchToEditMode(); + else if (level != null && ADOBase.customLevel == level && ADOBase.controller != null) { + level.ResetScene(); + level.Play(0); // Return to the game's normal press-to-start preparation. + } + }); + TryCleanup(restore.Restore); + } + if (State != RenderState.Completed && !string.IsNullOrEmpty(partialPath)) + TryCleanup(() => { if (File.Exists(partialPath)) File.Delete(partialPath); }); + foreach (var temporary in new[] { audioPath, muxPath }) + if (!string.IsNullOrEmpty(temporary)) TryCleanup(() => { if (File.Exists(temporary)) File.Delete(temporary); }); + ClearQueuedInput(); + } + private static void ClearQueuedInput() + { + try { AsyncInputManager.ClearKeys(); } catch { } + } + private void TryCleanup(Action action) + { + try { action(); } + catch (Exception ex) { Main.Entry.Logger.Error("Cleanup: " + ex); State = RenderState.Failed; Message = "Cleanup failed: " + ex.Message; } + } + private void OnDestroy() { StopAndClean(); if (Instance == this) Instance = null; } + private void OnApplicationQuit() { StopAndClean(); } + internal static string SanitizeName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + var name = new string((value ?? "Level").Select(c => invalid.Contains(c) || char.IsControl(c) ? '_' : c).ToArray()).Trim(' ', '.'); + return "Render_" + (string.IsNullOrEmpty(name) ? "Level" : name.Substring(0, Math.Min(80, name.Length))); + } + private sealed class SavedState + { + private readonly int captureRate = Time.captureFramerate, targetRate = Application.targetFrameRate, vsync = QualitySettings.vSyncCount, checkpoint = GCS.checkpointNum; + private readonly float timeScale = Time.timeScale, volume = AudioListener.volume; + private readonly bool auto = RDC.auto, noFail = ADOBase.controller.noFail, pauseAudio = AudioListener.pause, background = Application.runInBackground; + private readonly bool smoothTweens = DG.Tweening.DOTween.useSmoothDeltaTime; + private readonly bool noHud = RDC.noHud, noAutoHud = RDC.noAutoHud; + private readonly int renderInterval = UnityEngine.Rendering.OnDemandRendering.renderFrameInterval; + private readonly bool wasPaused = ADOBase.controller.paused, controllerEnabled = ADOBase.controller.enabled; + private readonly SkipIntroBehavior intro = Persistence.skipIntroBehavior; + private readonly int[] selection = ADOBase.editor != null ? ADOBase.editor.selectedFloors.Select(f => f.seqID).ToArray() : new int[0]; + public void Restore() + { + RestoreTiming(); + // Rendering always returns to editing, even if it was requested + // during playback. Restoring the old unpaused flag here would + // incorrectly turn editor.playMode back on with its conductor off. + if (ADOBase.controller != null && ADOBase.editor == null) + { + ADOBase.controller.paused = wasPaused; + ADOBase.controller.enabled = controllerEnabled; + } + var editor = ADOBase.editor; + if (editor != null && selection.Length > 0 && editor.floors.Count > selection.Max()) + { + if (selection.Length == 1) editor.SelectFloor(editor.floors[selection[0]], cameraJump: false); + else editor.MultiSelectFloors(editor.floors[selection.Min()], editor.floors[selection.Max()], setSelectPoint: true); + } + } + public void RestoreTiming() + { + Time.captureFramerate = captureRate; Time.timeScale = timeScale; + DG.Tweening.DOTween.useSmoothDeltaTime = smoothTweens; + UnityEngine.Rendering.OnDemandRendering.renderFrameInterval = renderInterval; + Application.targetFrameRate = targetRate; QualitySettings.vSyncCount = vsync; + Application.runInBackground = background; + AudioListener.volume = volume; AudioListener.pause = pauseAudio; + RDC.auto = auto; GCS.checkpointNum = checkpoint; Persistence.skipIntroBehavior = intro; + RDC.noHud = noHud; RDC.noAutoHud = noAutoHud; + if (ADOBase.controller != null) ADOBase.controller.noFail = noFail; + } + } + } +} diff --git a/ADOFAIRenderer/Renderer/RendererRpcServer.cs b/ADOFAIRenderer/Renderer/RendererRpcServer.cs new file mode 100644 index 0000000..a505192 --- /dev/null +++ b/ADOFAIRenderer/Renderer/RendererRpcServer.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using ADOFAIRenderer; + +namespace ADOFAIRenderer.Renderer +{ + internal sealed class RpcRenderRequest + { + public RpcRenderJob Job; + } + + internal sealed class RpcRenderOptions + { + public RendererPreset? Preset; + public int? Width; + public int? Height; + public int? Fps; + public int? BitrateMbps; + public float? EndDelaySeconds; + + public bool HasValues + { + get { return Preset.HasValue || Width.HasValue || Height.HasValue || Fps.HasValue + || BitrateMbps.HasValue || EndDelaySeconds.HasValue; } + } + + public object Snapshot() + { + return new + { + preset = Preset.HasValue ? Preset.Value.ToString() : null, + width = Width, + height = Height, + fps = Fps, + bitrateMbps = BitrateMbps, + endDelaySeconds = EndDelaySeconds + }; + } + } + + internal sealed class RpcCancelRequest + { + public string JobId; + } + + internal enum RpcJobState + { + Queued, + Loading, + Preparing, + Rendering, + Finishing, + Completed, + Failed, + Cancelled + } + + internal sealed class RpcRenderJob + { + private readonly object sync = new object(); + private RpcJobState state = RpcJobState.Queued; + private string error; + private string outputPath; + private long totalFrames; + private long capturedFrames; + private DateTime updatedUtc = DateTime.UtcNow; + + public RpcRenderJob(string id, string levelPath, bool captureAudio, RpcRenderOptions options) + { + Id = id; + LevelPath = levelPath; + CaptureAudio = captureAudio; + Options = options; + } + + public string Id { get; } + public string LevelPath { get; } + public bool CaptureAudio { get; } + public RpcRenderOptions Options { get; } + + public void SetState(RpcJobState value, string message = null) + { + lock (sync) + { + state = value; + if (!string.IsNullOrEmpty(message)) error = message; + updatedUtc = DateTime.UtcNow; + } + } + + public void SetProgress(long captured, long total, string output) + { + lock (sync) + { + capturedFrames = captured; + totalFrames = total; + if (!string.IsNullOrEmpty(output)) outputPath = output; + updatedUtc = DateTime.UtcNow; + } + } + + public void Fail(string message) { SetState(RpcJobState.Failed, message); } + public void Cancel() { SetState(RpcJobState.Cancelled); } + + public RpcJobState State + { + get + { + lock (sync) return state; + } + } + + public bool IsTerminal + { + get + { + var value = State; + return value == RpcJobState.Completed || value == RpcJobState.Failed || value == RpcJobState.Cancelled; + } + } + + public string OutputPath + { + get { lock (sync) return outputPath; } + } + + public object Snapshot() + { + lock (sync) + { + return new + { + id = Id, + state = state.ToString().ToLowerInvariant(), + levelPath = LevelPath, + settings = Options?.Snapshot(), + outputPath, + totalFrames, + capturedFrames, + progress = totalFrames > 0 ? Math.Min(1.0, capturedFrames / (double)totalFrames) : 0.0, + error, + updatedUtc = updatedUtc.ToString("o") + }; + } + } + } + + internal sealed class RendererRpcServer : IDisposable + { + private volatile RendererController controller; + private readonly int port; + private readonly HttpListener listener = new HttpListener(); + private readonly ConcurrentDictionary jobs = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + private Thread thread; + private volatile bool stopping; + + public RendererRpcServer(RendererController controller, int port) + { + this.controller = controller ?? throw new ArgumentNullException(nameof(controller)); + this.port = port; + } + + public void Start() + { + listener.Prefixes.Add("http://127.0.0.1:" + port + "/"); + listener.IgnoreWriteExceptions = true; + listener.Start(); + thread = new Thread(ListenLoop) { IsBackground = true, Name = "ADOFAI Renderer RPC" }; + thread.Start(); + Main.Entry.Logger.Log("Renderer RPC listening on http://127.0.0.1:" + port + "/"); + } + + public void Rebind(RendererController replacement) + { + if (replacement == null) return; + controller = replacement; + foreach (var job in jobs.Values) + { + if (job.State != RpcJobState.Queued && job.State != RpcJobState.Loading) continue; + job.SetState(RpcJobState.Queued); + replacement.EnqueueRpcRender(new RpcRenderRequest { Job = job }); + } + } + + private void ListenLoop() + { + while (!stopping) + { + HttpListenerContext context; + try { context = listener.GetContext(); } + catch (HttpListenerException) { break; } + catch (ObjectDisposedException) { break; } + catch (Exception ex) + { + if (!stopping) Main.Entry.Logger.Error("Renderer RPC listener: " + ex); + break; + } + ThreadPool.QueueUserWorkItem(_ => Handle(context)); + } + } + + private void Handle(HttpListenerContext context) + { + try + { + context.Response.Headers["Access-Control-Allow-Origin"] = "*"; + context.Response.Headers["Access-Control-Allow-Headers"] = "Content-Type"; + context.Response.Headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"; + if (context.Request.HttpMethod == "OPTIONS") + { + context.Response.StatusCode = 204; + context.Response.Close(); + return; + } + + var path = context.Request.Url.AbsolutePath.Trim('/'); + var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 1 && parts[0].Equals("health", StringComparison.OrdinalIgnoreCase)) + { + WriteJson(context, 200, new { ok = true, renderer = controller.State.ToString().ToLowerInvariant() }); + return; + } + if (parts.Length == 1 && parts[0].Equals("jobs", StringComparison.OrdinalIgnoreCase) && context.Request.HttpMethod == "GET") + { + WriteJson(context, 200, jobs.Values.Select(j => j.Snapshot()).ToArray()); + return; + } + if (parts.Length == 1 && parts[0].Equals("render", StringComparison.OrdinalIgnoreCase) + && context.Request.HttpMethod == "POST") + { + CreateJob(context); + return; + } + if (parts.Length >= 2 && parts[0].Equals("render", StringComparison.OrdinalIgnoreCase)) + { + var job = jobs.TryGetValue(parts[1], out var found) ? found : null; + if (job == null) { WriteJson(context, 404, new { error = "Unknown render job." }); return; } + if (parts.Length == 2 && context.Request.HttpMethod == "GET") + { + WriteJson(context, 200, job.Snapshot()); + return; + } + if (parts.Length == 3 && parts[2].Equals("download", StringComparison.OrdinalIgnoreCase) + && context.Request.HttpMethod == "GET") + { + Download(context, job); + return; + } + if (parts.Length == 3 && parts[2].Equals("cancel", StringComparison.OrdinalIgnoreCase) + && (context.Request.HttpMethod == "POST" || context.Request.HttpMethod == "DELETE")) + { + if (job.IsTerminal) { WriteJson(context, 409, new { error = "Render job has already finished." }); return; } + controller.EnqueueRpcCancel(new RpcCancelRequest { JobId = job.Id }); + WriteJson(context, 202, new { id = job.Id, state = "cancelling" }); + return; + } + } + WriteJson(context, 404, new { error = "Unknown renderer RPC endpoint." }); + } + catch (Exception ex) + { + Main.Entry.Logger.Error("Renderer RPC request: " + ex); + try { WriteJson(context, 500, new { error = ex.Message }); } catch { } + } + } + + private void CreateJob(HttpListenerContext context) + { + if (controller.Busy) + { + WriteJson(context, 409, new { error = "A render is already in progress." }); + return; + } + if (context.Request.ContentLength64 < 0 || context.Request.ContentLength64 > 1024 * 1024) + { + WriteJson(context, 413, new { error = "Request body is too large." }); + return; + } + string body; + using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8)) body = reader.ReadToEnd(); + var payload = JsonConvert.DeserializeObject(body ?? "{}"); + var path = payload?.LevelPath ?? payload?.Path; + if (string.IsNullOrWhiteSpace(path)) + { + WriteJson(context, 400, new { error = "JSON field 'levelPath' is required." }); + return; + } + try { path = Path.GetFullPath(path); } + catch (Exception ex) { WriteJson(context, 400, new { error = "Invalid levelPath: " + ex.Message }); return; } + if (!File.Exists(path)) + { + WriteJson(context, 400, new { error = "Level file does not exist: " + path }); + return; + } + var options = ParseOptions(payload, out var optionsError); + if (optionsError != null) + { + WriteJson(context, 400, new { error = optionsError }); + return; + } + var id = Guid.NewGuid().ToString("N"); + var job = new RpcRenderJob(id, path, + payload.CaptureAudio ?? payload.Audio ?? (Main.Settings == null || Main.Settings.CaptureAudio), options); + jobs[id] = job; + controller.EnqueueRpcRender(new RpcRenderRequest { Job = job }); + WriteJson(context, 202, new + { + id, + state = "queued", + statusUrl = "/render/" + id, + downloadUrl = "/render/" + id + "/download" + }); + } + + private static RpcRenderOptions ParseOptions(RpcRenderPayload payload, out string error) + { + error = null; + if (payload == null) return null; + RendererPreset? preset = null; + if (!string.IsNullOrWhiteSpace(payload.Preset)) + { + if (!TryParsePreset(payload.Preset, out var parsed)) + { + error = "Unknown preset. Use Custom, Preview, FullHD, QHD, or UHD4K."; + return null; + } + preset = parsed; + } + if (payload.Width.HasValue && (payload.Width.Value < 320 || payload.Width.Value > 3840)) + { + error = InvalidOption("width", "320..3840"); + return null; + } + if (payload.Height.HasValue && (payload.Height.Value < 180 || payload.Height.Value > 2160)) + { + error = InvalidOption("height", "180..2160"); + return null; + } + var fps = payload.Fps ?? payload.TargetFps; + if (payload.Fps.HasValue && payload.TargetFps.HasValue && payload.Fps.Value != payload.TargetFps.Value) + { + error = "Use either 'fps' or 'targetFps'; both values must match."; + return null; + } + if (fps.HasValue && (fps.Value < 15 || fps.Value > 240)) + { + error = InvalidOption("fps", "15..240"); + return null; + } + var bitrate = payload.BitrateMbps ?? payload.Bitrate; + if (payload.BitrateMbps.HasValue && payload.Bitrate.HasValue && payload.BitrateMbps.Value != payload.Bitrate.Value) + { + error = "Use either 'bitrateMbps' or 'bitrate'; both values must match."; + return null; + } + if (bitrate.HasValue && (bitrate.Value < 1 || bitrate.Value > 200)) + { + error = InvalidOption("bitrateMbps", "1..200"); + return null; + } + if (payload.EndDelaySeconds.HasValue && (float.IsNaN(payload.EndDelaySeconds.Value) + || float.IsInfinity(payload.EndDelaySeconds.Value) || payload.EndDelaySeconds.Value < 0f + || payload.EndDelaySeconds.Value > 30f)) + { + error = InvalidOption("endDelaySeconds", "0..30"); + return null; + } + var options = new RpcRenderOptions { + Preset = preset, + Width = payload.Width, + Height = payload.Height, + Fps = fps, + BitrateMbps = bitrate, + EndDelaySeconds = payload.EndDelaySeconds + }; + return options.HasValues ? options : null; + } + + private static string InvalidOption(string name, string range) + { + return "Option '" + name + "' must be in range " + range + "."; + } + + private static bool TryParsePreset(string value, out RendererPreset preset) + { + switch ((value ?? string.Empty).Trim().ToLowerInvariant()) + { + case "custom": preset = RendererPreset.Custom; return true; + case "preview": preset = RendererPreset.Preview; return true; + case "fullhd": case "1080p": preset = RendererPreset.FullHD; return true; + case "qhd": case "1440p": preset = RendererPreset.QHD; return true; + case "uhd4k": case "4k": case "2160p": preset = RendererPreset.UHD4K; return true; + default: preset = RendererPreset.Custom; return false; + } + } + + private static void Download(HttpListenerContext context, RpcRenderJob job) + { + var path = job.OutputPath; + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + WriteJson(context, job.IsTerminal ? 409 : 404, + new { error = job.IsTerminal ? "Render output is unavailable." : "Render is not complete." }); + return; + } + var info = new FileInfo(path); + context.Response.StatusCode = 200; + context.Response.ContentType = "video/mp4"; + context.Response.ContentLength64 = info.Length; + context.Response.AddHeader("Content-Disposition", "attachment; filename=\"" + Uri.EscapeDataString(info.Name) + "\""); + using (var file = File.OpenRead(path)) file.CopyTo(context.Response.OutputStream); + context.Response.Close(); + } + + private static void WriteJson(HttpListenerContext context, int status, object value) + { + var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value)); + context.Response.StatusCode = status; + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.ContentLength64 = bytes.Length; + using (var stream = context.Response.OutputStream) stream.Write(bytes, 0, bytes.Length); + context.Response.Close(); + } + + public void Dispose() + { + stopping = true; + try { listener.Stop(); } catch { } + try { listener.Close(); } catch { } + if (thread != null && thread.IsAlive && !thread.Join(500)) + Main.Entry.Logger.Log("Renderer RPC listener thread did not stop immediately."); + thread = null; + } + + private sealed class RpcRenderPayload + { + [JsonProperty("levelPath")] public string LevelPath { get; set; } + [JsonProperty("path")] public string Path { get; set; } + [JsonProperty("preset")] public string Preset { get; set; } + [JsonProperty("width")] public int? Width { get; set; } + [JsonProperty("height")] public int? Height { get; set; } + [JsonProperty("fps")] public int? Fps { get; set; } + [JsonProperty("targetFps")] public int? TargetFps { get; set; } + [JsonProperty("bitrateMbps")] public int? BitrateMbps { get; set; } + [JsonProperty("bitrate")] public int? Bitrate { get; set; } + [JsonProperty("endDelaySeconds")] public float? EndDelaySeconds { get; set; } + [JsonProperty("captureAudio")] public bool? CaptureAudio { get; set; } + [JsonProperty("audio")] public bool? Audio { get; set; } + } + } +} diff --git a/ADOFAIRenderer/RendererSettings.cs b/ADOFAIRenderer/RendererSettings.cs new file mode 100644 index 0000000..fa205fe --- /dev/null +++ b/ADOFAIRenderer/RendererSettings.cs @@ -0,0 +1,155 @@ +using System; +using UnityModManagerNet; + +namespace ADOFAIRenderer +{ + public enum RendererPreset + { + Custom, + Preview, + FullHD, + QHD, + UHD4K + } + + internal sealed class RenderProfile + { + public RenderProfile(int width, int height, int fps, int bitrateMbps, string ffmpegPreset, float endDelaySeconds = 2f) + { + Width = width; + Height = height; + Fps = fps; + BitrateMbps = bitrateMbps; + FfmpegPreset = ffmpegPreset; + EndDelaySeconds = endDelaySeconds; + } + + public int Width { get; } + public int Height { get; } + public int Fps { get; } + public int BitrateMbps { get; } + public string FfmpegPreset { get; } + public float EndDelaySeconds { get; } + } + + public sealed class RendererSettings : UnityModManager.ModSettings, IDrawable + { + private const int MinWidth = 320; + private const int MaxWidth = 3840; + private const int MinHeight = 180; + private const int MaxHeight = 2160; + private const int MinFps = 15; + private const int MaxFps = 240; + private const int MinBitrate = 1; + private const int MaxBitrate = 200; + + [Draw("Preset", DrawType.PopupList)] + public RendererPreset Preset = RendererPreset.FullHD; + + [Draw("Width", DrawType.Field, Min = MinWidth, Max = MaxWidth, VisibleOn = "Preset|Custom")] + public int Width = 1920; + + [Draw("Height", DrawType.Field, Min = MinHeight, Max = MaxHeight, VisibleOn = "Preset|Custom")] + public int Height = 1080; + + [Draw("Target FPS", DrawType.Field, Min = MinFps, Max = MaxFps, VisibleOn = "Preset|Custom")] + public int Fps = 60; + + [Draw("Video bitrate (Mbps)", DrawType.Field, Min = MinBitrate, Max = MaxBitrate, VisibleOn = "Preset|Custom")] + public int BitrateMbps = 18; + + [Draw("End delay (seconds)", DrawType.Field, Min = 0, Max = 30, Precision = 2)] + public float EndDelaySeconds = 2f; + + [Draw("Capture audio", DrawType.Toggle)] + public bool CaptureAudio = true; + + public void OnChange() + { + // Selecting a built-in preset also copies its values into the + // fields, so switching to Custom starts from a useful baseline. + if (Preset != RendererPreset.Custom) + { + var profile = GetPresetProfile(Preset); + Width = profile.Width; + Height = profile.Height; + Fps = profile.Fps; + BitrateMbps = profile.BitrateMbps; + } + Width = EvenClamp(Width, MinWidth, MaxWidth); + Height = EvenClamp(Height, MinHeight, MaxHeight); + Fps = Clamp(Fps, MinFps, MaxFps); + BitrateMbps = Clamp(BitrateMbps, MinBitrate, MaxBitrate); + if (float.IsNaN(EndDelaySeconds) || float.IsInfinity(EndDelaySeconds)) EndDelaySeconds = 2f; + EndDelaySeconds = Math.Max(0f, Math.Min(30f, EndDelaySeconds)); + } + + internal RenderProfile ResolveProfile() + { + return ResolveProfile(null, null, null, null, null, null); + } + + internal RenderProfile ResolveProfile(RendererPreset? presetOverride, int? widthOverride, + int? heightOverride, int? fpsOverride, int? bitrateOverride, float? endDelayOverride) + { + var hasVideoOverride = presetOverride.HasValue || widthOverride.HasValue || heightOverride.HasValue + || fpsOverride.HasValue || bitrateOverride.HasValue; + var preset = presetOverride ?? (hasVideoOverride ? RendererPreset.Custom : Preset); + var baseProfile = preset == RendererPreset.Custom + ? new RenderProfile( + EvenClamp(Width, MinWidth, MaxWidth), + EvenClamp(Height, MinHeight, MaxHeight), + Clamp(Fps, MinFps, MaxFps), + Clamp(BitrateMbps, MinBitrate, MaxBitrate), + "fast", EndDelaySeconds) + : GetPresetProfile(preset); + var endDelay = endDelayOverride.HasValue + ? Clamp(endDelayOverride.Value, 0f, 30f) + : baseProfile.EndDelaySeconds; + return new RenderProfile( + widthOverride.HasValue ? EvenClamp(widthOverride.Value, MinWidth, MaxWidth) : baseProfile.Width, + heightOverride.HasValue ? EvenClamp(heightOverride.Value, MinHeight, MaxHeight) : baseProfile.Height, + fpsOverride.HasValue ? Clamp(fpsOverride.Value, MinFps, MaxFps) : baseProfile.Fps, + bitrateOverride.HasValue ? Clamp(bitrateOverride.Value, MinBitrate, MaxBitrate) : baseProfile.BitrateMbps, + baseProfile.FfmpegPreset, endDelay); + } + + public override void Save(UnityModManager.ModEntry modEntry) + { + Save(this, modEntry); + } + + public static RendererSettings Load(UnityModManager.ModEntry modEntry) + { + return UnityModManager.ModSettings.Load(modEntry) ?? new RendererSettings(); + } + + private static RenderProfile GetPresetProfile(RendererPreset preset) + { + switch (preset) + { + case RendererPreset.Preview: return new RenderProfile(1280, 720, 30, 8, "veryfast"); + case RendererPreset.QHD: return new RenderProfile(2560, 1440, 60, 30, "veryfast"); + case RendererPreset.UHD4K: return new RenderProfile(3840, 2160, 60, 50, "fast"); + case RendererPreset.FullHD: + default: return new RenderProfile(1920, 1080, 60, 18, "veryfast"); + } + } + + private static int Clamp(int value, int min, int max) + { + return Math.Max(min, Math.Min(max, value)); + } + + private static float Clamp(float value, float min, float max) + { + return Math.Max(min, Math.Min(max, value)); + } + + private static int EvenClamp(int value, int min, int max) + { + var result = Clamp(value, min, max); + return (result & 1) == 0 ? result : result == max ? result - 1 : result + 1; + } + } +} diff --git a/ADOFAIRenderer/UI/RendererWindow.cs b/ADOFAIRenderer/UI/RendererWindow.cs new file mode 100644 index 0000000..2b57a87 --- /dev/null +++ b/ADOFAIRenderer/UI/RendererWindow.cs @@ -0,0 +1,62 @@ +using UnityEngine; +using ADOFAIRenderer.Renderer; + +namespace ADOFAIRenderer.UI +{ + internal static class RendererWindow + { + private static bool initialized; + private static GUIStyle panel, title, message, detail; + private static Texture2D background; + + internal static void DrawToast(RendererController renderer) + { + EnsureStyles(); + float width = Mathf.Min(620f, Screen.width - 40f); + float left = (Screen.width - width) * 0.5f; + GUILayout.BeginArea(new Rect(left, 26f, width, 86f), panel); + GUILayout.BeginVertical(); + GUILayout.Label("ADOFAI RENDERER", title); + GUILayout.Label(renderer.ToastText ?? renderer.Message ?? string.Empty, message); + if (renderer.TotalFrames > 0) + { + float progress = Mathf.Clamp01((float)renderer.CapturedFrames / renderer.TotalFrames); + GUILayout.Label(string.Format("{0:F1}% {1} / {2} frames {3:F1} fps", + progress * 100f, renderer.CapturedFrames, renderer.TotalFrames, renderer.GenerationFps), detail); + } + GUILayout.EndVertical(); + GUILayout.EndArea(); + } + + private static void EnsureStyles() + { + if (initialized) return; + initialized = true; + background = new Texture2D(1, 1, TextureFormat.RGBA32, false); + background.SetPixel(0, 0, new Color(0.035f, 0.045f, 0.07f, 0.96f)); + background.Apply(); + panel = new GUIStyle(GUI.skin.box) + { + padding = new RectOffset(18, 18, 12, 12), + normal = { background = background } + }; + title = new GUIStyle(GUI.skin.label) + { + fontSize = 15, + fontStyle = FontStyle.Bold, + normal = { textColor = new Color(0.82f, 0.93f, 1f) } + }; + message = new GUIStyle(GUI.skin.label) + { + fontSize = 13, + fontStyle = FontStyle.Bold, + normal = { textColor = Color.white } + }; + detail = new GUIStyle(GUI.skin.label) + { + fontSize = 11, + normal = { textColor = new Color(0.55f, 0.80f, 0.90f) } + }; + } + } +} diff --git a/Tests/Program.cs b/Tests/Program.cs new file mode 100644 index 0000000..9adf7f0 --- /dev/null +++ b/Tests/Program.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using ADOFAIRenderer.Renderer; + +internal static class Program +{ + static void Assert(bool condition, string message) { if (!condition) throw new Exception(message); } + static int Main(string[] args) + { + try + { + if (args.Length != 2) throw new ArgumentException("Pass ffmpeg.exe and a test output directory."); + Directory.CreateDirectory(args[1]); + var clock = new RenderClock(); + for (int i = 0; i < 60 * 60 * 4 * 60; i++) clock.Advance(); + Assert(clock.Time == 14400, "Four-hour clock drift."); + Assert(Math.Abs(clock.SongPosition(1001, 1.5, 0.2, 0.03) - ((14399 - 0.03) * 1.5 - 0.2)) < 1e-9, "Pitch/offset mapping failed."); + var anchored = new RenderClock(); + anchored.AnchorDsp(12345.5); + for (int i = 0; i < 60; i++) anchored.Advance(); + Assert(anchored.DspTime == 12346.5 && anchored.Time == 1, "Audio DSP anchoring changed virtual frame time."); + bool anchorRejected = false; + try { anchored.AnchorDsp(0); } catch (InvalidOperationException) { anchorRejected = true; } + Assert(anchorRejected, "DSP clock was allowed to reanchor during rendering."); + var fast = Path.Combine(args[1], "fast.mp4"); + var slow = Path.Combine(args[1], "slow.mp4"); + Encode(args[0], fast, false); + Encode(args[0], slow, true); + var fastHash = Probe(args[0], "-v error -i \"" + fast + "\" -f framemd5 -"); + var slowHash = Probe(args[0], "-v error -i \"" + slow + "\" -f framemd5 -"); + Assert(fastHash == slowHash, "Different wall-clock delays changed decoded frames."); + Assert(fastHash.Contains("#tb 0: 1/60") && fastHash.Contains("#dimensions 0: 1920x1080"), "Wrong frame rate or resolution."); + var decoded = fastHash.Split(new[] {'\n'}, StringSplitOptions.RemoveEmptyEntries).Where(line => !line.StartsWith("#")).ToArray(); + Assert(decoded.Length == 60, "Decoded frame count mismatch."); + Assert(decoded.Select(line => line.Split(',').Last().Trim()).Distinct().Count() == 60, "Duplicate decoded frames."); + for (int i = 0; i < decoded.Length; i++) + { + var columns = decoded[i].Split(','); + Assert(long.Parse(columns[2]) == i && int.Parse(columns[3]) == 1, "Frame timestamp or duration mismatch."); + } + using (var encoder = new FFmpegEncoder(args[0], Path.Combine(args[1], "bad-order.mp4"))) + { + var frame = encoder.Rent(); frame.Index = 1; encoder.Submit(frame); + bool failed = false; + try { encoder.Finish(1); } catch (IOException) { failed = true; } + Assert(failed, "Out-of-order frames were silently accepted."); + } + using (var encoder = new FFmpegEncoder(args[0], Path.Combine(args[1], "cancelled.mp4"))) + { + var frame = encoder.Rent(); frame.Index = 0; encoder.Submit(frame); + } + bool rejected = false; + try { using (var encoder = new FFmpegEncoder(args[0], Path.Combine(args[1], "missing", "failure.mp4"))) { + var frame = encoder.Rent(); frame.Index = 0; encoder.Submit(frame); encoder.Finish(1); + }} catch (IOException) { rejected = true; } + Assert(rejected, "FFmpeg nonzero exit was ignored."); + var wav = Path.Combine(args[1], "tone.wav"); + var muxed = Path.Combine(args[1], "with-audio.mp4"); + Probe(args[0], "-v error -f lavfi -i sine=frequency=440:sample_rate=48000:duration=1 -ac 2 -c:a pcm_f32le \"" + wav + "\""); + FFmpegEncoder.MuxAudio(args[0], fast, wav, muxed); + var muxedVideoHash = Probe(args[0], "-v error -i \"" + muxed + "\" -map 0:v:0 -f framemd5 -"); + Assert(muxedVideoHash == fastHash, "Audio mux changed video frames or timestamps."); + var metadata = Probe(Path.Combine(Path.GetDirectoryName(args[0]), "ffprobe.exe"), + "-v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels,duration -of default=noprint_wrappers=1 \"" + muxed + "\""); + Assert(metadata.Contains("codec_name=aac") && metadata.Contains("sample_rate=48000") && metadata.Contains("channels=2") && metadata.Contains("duration=1.000000"), "Muxed audio format/duration mismatch."); + Console.WriteLine("PASS: four-hour clock, DSP anchoring, pitch/offset, 1080p60/60 frames, frame order, identical fast/slow video, failure, cancellation, AAC mux and matching A/V duration."); + return 0; + } + catch (Exception ex) { Console.Error.WriteLine(ex); return 1; } + } + private static void Encode(string ffmpeg, string output, bool slow) + { + using (var encoder = new FFmpegEncoder(ffmpeg, output)) + { + for (int i = 0; i < 60; i++) + { + var frame = encoder.Rent(); frame.Index = i; + for (int j = 0; j < frame.Bytes.Length; j += 4) { + frame.Bytes[j] = (byte)(i * 4); frame.Bytes[j+1] = (byte)((j / (1920 * 4)) % 256); + frame.Bytes[j+2] = (byte)(255 - i * 4); frame.Bytes[j+3] = 255; + } + if (slow && i % 10 == 0) Thread.Sleep(100); + encoder.Submit(frame); + } + encoder.Finish(60); + } + } + private static string Probe(string ffmpeg, string arguments) + { + using (var process = Process.Start(new ProcessStartInfo(ffmpeg, arguments) { + UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true + })) { + string result = process.StandardOutput.ReadToEnd(); process.WaitForExit(); + Assert(process.ExitCode == 0, "Video decode failed."); return result; + } + } +} diff --git a/Tests/RendererTests.csproj b/Tests/RendererTests.csproj new file mode 100644 index 0000000..eb3efe1 --- /dev/null +++ b/Tests/RendererTests.csproj @@ -0,0 +1,17 @@ + + + + ExeRendererTestsv4.8 + latestbin\Release\ + $(MSBuildProjectDirectory)\..\packages\net48\build\ + $(MSBuildProjectDirectory)\..\packages\net48\build\.NETFramework\v4.8 + + + false + falsefalse + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..ec8226d --- /dev/null +++ b/build.ps1 @@ -0,0 +1,66 @@ +param( + [string]$GameDir = 'C:\Program Files (x86)\Steam\steamapps\common\A Dance of Fire and Ice', + [string]$MSBuildPath, + [switch]$FetchFFmpeg, + [switch]$Test +) +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-StrictMode -Version Latest +Set-Location $PSScriptRoot + + +# Build dependencies remain local and ignored. No game DLL is redistributed. +function Get-Package([string]$Id, [string]$Version, [string]$Destination, [string]$Expected) { + if (Test-Path -LiteralPath (Join-Path $Destination $Expected)) { return } + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + $archive = Join-Path $Destination 'package.zip' + Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/$Id/$Version/$Id.$Version.nupkg" -OutFile $archive + Expand-Archive -LiteralPath $archive -DestinationPath $Destination -Force + Remove-Item -LiteralPath $archive + if (!(Test-Path -LiteralPath (Join-Path $Destination $Expected))) { throw "Package $Id is incomplete." } +} +Get-Package 'unitymodmanager' '0.32.4' 'packages/UnityModManager' 'lib/net35/UnityModManager.dll' +Get-Package 'microsoft.netframework.referenceassemblies.net48' '1.0.3' 'packages/net48' 'build/.NETFramework/v4.8/mscorlib.dll' +if (!(Test-Path 'packages/0Harmony.dll')) { + Get-Package 'lib.harmony' '2.2.2' 'packages/Harmony' 'lib/net48/0Harmony.dll' + Copy-Item -LiteralPath 'packages/Harmony/lib/net48/0Harmony.dll' -Destination 'packages/0Harmony.dll' +} +if (!$MSBuildPath) { + $command = Get-Command MSBuild.exe -ErrorAction SilentlyContinue + if ($command) { $MSBuildPath = $command.Source } + else { + $rider = Get-ChildItem "${env:ProgramFiles}/JetBrains" -Filter 'JetBrains Rider *' -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | Select-Object -First 1 + if ($rider) { $MSBuildPath = Join-Path $rider.FullName 'tools/MSBuild/Current/Bin/MSBuild.exe' } + } +} +if (!$MSBuildPath -or !(Test-Path -LiteralPath $MSBuildPath)) { throw 'Pass -MSBuildPath with Visual Studio or Rider MSBuild.exe.' } +if (!(Test-Path -LiteralPath "$GameDir/A Dance of Fire and Ice_Data/Managed/Assembly-CSharp.dll")) { throw 'Pass -GameDir with your ADOFAI installation.' } +& $MSBuildPath ADOFAIRenderer.sln /t:Rebuild /p:Configuration=Release "/p:GameDir=$GameDir" /v:minimal /nologo +if ($LASTEXITCODE -ne 0) { throw 'Mod build failed.' } + +$ffmpeg = Get-ChildItem packages/ffmpeg -Filter ffmpeg.exe -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($FetchFFmpeg -and !$ffmpeg) { + New-Item -ItemType Directory -Force packages/ffmpeg | Out-Null + $archive = Join-Path $PSScriptRoot 'packages/ffmpeg/essentials.zip' + # Windows build linked by ffmpeg.org/download.html; keep its original package + # (including licenses/source links) under packages rather than replacing game FFmpeg. + Invoke-WebRequest 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip' -OutFile $archive + Expand-Archive -LiteralPath $archive -DestinationPath packages/ffmpeg -Force + Remove-Item -LiteralPath $archive + $ffmpeg = Get-ChildItem packages/ffmpeg -Filter ffmpeg.exe -Recurse | Select-Object -First 1 +} +if ($ffmpeg) { + Copy-Item -LiteralPath $ffmpeg.FullName -Destination ADOFAIRenderer/bin/Release/ffmpeg.exe -Force +} +if ($Test) { + if (!$ffmpeg) { throw 'Run with -FetchFFmpeg -Test to install an encoding-capable FFmpeg.' } + & $MSBuildPath Tests/RendererTests.csproj /t:Rebuild /v:minimal /nologo + if ($LASTEXITCODE -ne 0) { throw 'Test build failed.' } + $testOutput = Join-Path $env:TEMP ('adofai-render-tests-' + [guid]::NewGuid().ToString('N')) + & ./Tests/bin/Release/RendererTests.exe $ffmpeg.FullName $testOutput + if ($LASTEXITCODE -ne 0) { throw 'Renderer tests failed.' } + Write-Host "Test videos: $testOutput" +} +Write-Host "Mod output: $PSScriptRoot/ADOFAIRenderer/bin/Release"