This commit is contained in:
암냥 2026-09-07 23:32:35 +09:00
commit 3a93a5a66e
22 changed files with 2723 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

15
.idea/.idea.ADOFAIRenderer/.idea/.gitignore generated vendored Normal file
View file

@ -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

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

16
ADOFAIRenderer.sln Normal file
View file

@ -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

View file

@ -0,0 +1,37 @@
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" />
<PropertyGroup>
<Configuration Condition="'$(Configuration)' == ''">Debug</Configuration>
<ProjectGuid>{6D295B50-05F2-474C-BC70-B38B0FC0A3FF}</ProjectGuid>
<OutputType>Library</OutputType><TargetFrameworkVersion>v4.8</TargetFrameworkVersion><LangVersion>latest</LangVersion>
<AssemblyName>ADOFAIRenderer</AssemblyName><RootNamespace>ADOFAIRenderer</RootNamespace>
<OutputPath>bin\$(Configuration)\</OutputPath><Optimize>true</Optimize>
<TargetFrameworkRootPath Condition="Exists('..\packages\net48\build\.NETFramework\v4.8')">$(MSBuildProjectDirectory)\..\packages\net48\build\</TargetFrameworkRootPath>
<GameDir Condition="'$(GameDir)' == ''">C:\Program Files (x86)\Steam\steamapps\common\A Dance of Fire and Ice</GameDir>
<ManagedDir>$(GameDir)\A Dance of Fire and Ice_Data\Managed</ManagedDir>
<ModManagerDir Condition="'$(ModManagerDir)' == ''">$(ManagedDir)\UnityModManager</ModManagerDir>
<FrameworkPathOverride Condition="Exists('..\packages\net48\build\.NETFramework\v4.8')">$(MSBuildProjectDirectory)\..\packages\net48\build\.NETFramework\v4.8</FrameworkPathOverride>
</PropertyGroup>
<ItemGroup>
<Reference Include="System"/><Reference Include="System.Core"/>
<Reference Include="$(ManagedDir)\netstandard.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\UnityEngine*.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\Unity.Collections.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\Assembly-CSharp.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\Assembly-CSharp-firstpass.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\RDTools.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\DOTween.dll"><Private>false</Private></Reference>
<Reference Include="$(ManagedDir)\Newtonsoft.Json.dll"><Private>false</Private></Reference>
<Reference Include="UnityModManager">
<HintPath Condition="Exists('$(ModManagerDir)\UnityModManager.dll')">$(ModManagerDir)\UnityModManager.dll</HintPath>
<HintPath Condition="!Exists('$(ModManagerDir)\UnityModManager.dll')">..\packages\UnityModManager\lib\net35\UnityModManager.dll</HintPath><Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath Condition="Exists('$(ModManagerDir)\0Harmony.dll')">$(ModManagerDir)\0Harmony.dll</HintPath>
<HintPath Condition="!Exists('$(ModManagerDir)\0Harmony.dll')">..\packages\0Harmony.dll</HintPath><Private>false</Private>
</Reference>
<Compile Include="**\*.cs" Exclude="obj\**;bin\**"/>
<Content Include="Info.json"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></Content>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets"/>
</Project>

1
ADOFAIRenderer/Info.json Normal file
View file

@ -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"}

134
ADOFAIRenderer/Main.cs Normal file
View file

@ -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<RendererController>();
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<RendererController>();
RpcServer?.Rebind(RendererController.Instance);
Entry.Logger.Log("Renderer host recreated after scene load: " + scene.name);
}
}
}
}

View file

@ -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<System.Reflection.MethodBase> 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<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> 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<System.Reflection.MethodBase> 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);
}
}
}

View file

@ -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<MethodBase> TargetMethods()
{
yield return AccessTools.Method(typeof(RDInput), "GetMainPressKeys");
yield return AccessTools.Method(typeof(RDInput), "GetMainHeldKeys");
}
static bool Prefix(ref List<AnyKeyCode> __result)
{
if (!RendererController.ControlsTime) return true;
__result = new List<AnyKeyCode>();
return false;
}
}
[HarmonyPatch]
internal static class RendererBackInputPatch
{
static IEnumerable<MethodBase> 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<MethodBase> 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<MethodBase> 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;
}
}
}

View file

@ -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<CodeInstruction> Transpiler(IEnumerable<CodeInstruction> 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.");
}
}
}

View file

@ -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")]

View file

@ -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<Frame> free;
private readonly BlockingCollection<Frame> 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<Frame>(bufferCount);
work = new BlockingCollection<Frame>(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();
}
}
}

View file

@ -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<byte>();
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<CameraState> cameras = new List<CameraState>();
private readonly List<CanvasState> canvases = new List<CanvasState>();
private readonly Queue<Pending> pending = new Queue<Pending>();
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<Canvas>(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<byte>().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); }
}
}
}

View file

@ -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<float> 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<float>(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;
}
}
}
}

View file

@ -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++; } }
}
}

View file

@ -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<object> rpcCommands = new ConcurrentQueue<object>();
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<scrPressToStart>().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;
}
}
}
}

View file

@ -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<string, RpcRenderJob> jobs =
new ConcurrentDictionary<string, RpcRenderJob>(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<RpcRenderPayload>(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; }
}
}
}

View file

@ -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<RendererSettings>(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;
}
}
}

View file

@ -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) }
};
}
}
}

100
Tests/Program.cs Normal file
View file

@ -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;
}
}
}

View file

@ -0,0 +1,17 @@
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"/>
<PropertyGroup>
<OutputType>Exe</OutputType><AssemblyName>RendererTests</AssemblyName><TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<LangVersion>latest</LangVersion><OutputPath>bin\Release\</OutputPath>
<TargetFrameworkRootPath>$(MSBuildProjectDirectory)\..\packages\net48\build\</TargetFrameworkRootPath>
<FrameworkPathOverride Condition="Exists('..\packages\net48\build\.NETFramework\v4.8')">$(MSBuildProjectDirectory)\..\packages\net48\build\.NETFramework\v4.8</FrameworkPathOverride>
</PropertyGroup>
<ItemGroup>
<Reference Include="mscorlib"><Private>false</Private></Reference>
<Reference Include="System"><Private>false</Private></Reference><Reference Include="System.Core"><Private>false</Private></Reference>
<Compile Include="Program.cs"/>
<Compile Include="..\ADOFAIRenderer\Renderer\RenderClock.cs" Link="RenderClock.cs"/>
<Compile Include="..\ADOFAIRenderer\Renderer\FFmpegEncoder.cs" Link="FFmpegEncoder.cs"/>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets"/>
</Project>

66
build.ps1 Normal file
View file

@ -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"