wow
This commit is contained in:
commit
3a93a5a66e
22 changed files with 2723 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
15
.idea/.idea.ADOFAIRenderer/.idea/.gitignore
generated
vendored
Normal file
15
.idea/.idea.ADOFAIRenderer/.idea/.gitignore
generated
vendored
Normal 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
|
||||||
8
.idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml
generated
Normal file
8
.idea/.idea.ADOFAIRenderer/.idea/indexLayout.xml
generated
Normal 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
16
ADOFAIRenderer.sln
Normal 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
|
||||||
37
ADOFAIRenderer/ADOFAIRenderer.csproj
Normal file
37
ADOFAIRenderer/ADOFAIRenderer.csproj
Normal 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
1
ADOFAIRenderer/Info.json
Normal 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
134
ADOFAIRenderer/Main.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
ADOFAIRenderer/Patches/ConductorPatch.cs
Normal file
102
ADOFAIRenderer/Patches/ConductorPatch.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
ADOFAIRenderer/Patches/GameplayPatch.cs
Normal file
191
ADOFAIRenderer/Patches/GameplayPatch.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ADOFAIRenderer/Patches/TweenClockPatch.cs
Normal file
35
ADOFAIRenderer/Patches/TweenClockPatch.cs
Normal 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
ADOFAIRenderer/Properties/AssemblyInfo.cs
Normal file
35
ADOFAIRenderer/Properties/AssemblyInfo.cs
Normal 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")]
|
||||||
159
ADOFAIRenderer/Renderer/FFmpegEncoder.cs
Normal file
159
ADOFAIRenderer/Renderer/FFmpegEncoder.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
ADOFAIRenderer/Renderer/FrameCapture.cs
Normal file
227
ADOFAIRenderer/Renderer/FrameCapture.cs
Normal 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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
101
ADOFAIRenderer/Renderer/GameAudioCapture.cs
Normal file
101
ADOFAIRenderer/Renderer/GameAudioCapture.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
ADOFAIRenderer/Renderer/RenderClock.cs
Normal file
28
ADOFAIRenderer/Renderer/RenderClock.cs
Normal 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++; } }
|
||||||
|
}
|
||||||
|
}
|
||||||
769
ADOFAIRenderer/Renderer/RendererController.cs
Normal file
769
ADOFAIRenderer/Renderer/RendererController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
460
ADOFAIRenderer/Renderer/RendererRpcServer.cs
Normal file
460
ADOFAIRenderer/Renderer/RendererRpcServer.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
155
ADOFAIRenderer/RendererSettings.cs
Normal file
155
ADOFAIRenderer/RendererSettings.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
ADOFAIRenderer/UI/RendererWindow.cs
Normal file
62
ADOFAIRenderer/UI/RendererWindow.cs
Normal 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
100
Tests/Program.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Tests/RendererTests.csproj
Normal file
17
Tests/RendererTests.csproj
Normal 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
66
build.ps1
Normal 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"
|
||||||
Loading…
Reference in a new issue