1107 lines
38 KiB
C#
1107 lines
38 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using KAsyncEngineLib;
|
|
|
|
namespace Tornado3_2026Election.Services;
|
|
|
|
public sealed class TornadoManager : IDisposable
|
|
{
|
|
private static readonly TimeSpan AutoReconnectInterval = TimeSpan.FromSeconds(5);
|
|
private static readonly TimeSpan AsyncOperationTimeout = TimeSpan.FromSeconds(30);
|
|
|
|
private readonly string _host;
|
|
private readonly int _port;
|
|
private readonly LogService _logService;
|
|
private readonly StaDispatcher _dispatcher;
|
|
private readonly Timer _reconnectTimer;
|
|
private readonly Dictionary<string, KAScene> _scenes = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, string> _scenePaths = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<string, DateTime> _sceneWriteTimes = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly Dictionary<int, KAScenePlayer> _scenePlayers = new();
|
|
|
|
private IKAEngine? _engine;
|
|
private KarismaEventHandler? _eventHandler;
|
|
private bool _connected;
|
|
private bool _isTcpConnected;
|
|
private bool _autoReconnectEnabled;
|
|
private bool _disposed;
|
|
private int _reconnectInProgress;
|
|
|
|
public bool IsConnected => _isTcpConnected;
|
|
|
|
public event EventHandler? ConnectionChanged;
|
|
|
|
public TornadoManager(string host, int port, LogService logService)
|
|
{
|
|
_host = string.IsNullOrWhiteSpace(host)
|
|
? throw new ArgumentException("Host is required.", nameof(host))
|
|
: host;
|
|
_port = port > 0
|
|
? port
|
|
: throw new ArgumentOutOfRangeException(nameof(port), "Port must be a positive integer.");
|
|
_logService = logService;
|
|
_dispatcher = new StaDispatcher("Karisma Async Engine");
|
|
_reconnectTimer = new Timer(OnReconnectTimerTick, null, AutoReconnectInterval, AutoReconnectInterval);
|
|
}
|
|
|
|
public Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
|
{
|
|
return EnsureConnectedInternalAsync(cancellationToken);
|
|
}
|
|
|
|
public Task<string> LoadSceneAsync(string scenePath, string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
return LoadSceneAsync(scenePath, sceneAlias, forceReload: false, cancellationToken);
|
|
}
|
|
|
|
public async Task<string> LoadSceneAsync(
|
|
string scenePath,
|
|
string sceneAlias,
|
|
bool forceReload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scenePath))
|
|
{
|
|
throw new ArgumentException("Scene path is required.", nameof(scenePath));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(sceneAlias))
|
|
{
|
|
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
|
}
|
|
|
|
var sceneWriteTime = ResolveSceneWriteTime(scenePath);
|
|
var existingAlias = await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
if (!forceReload &&
|
|
_scenePaths.TryGetValue(sceneAlias, out var existingPath) &&
|
|
string.Equals(existingPath, scenePath, StringComparison.OrdinalIgnoreCase) &&
|
|
_sceneWriteTimes.TryGetValue(sceneAlias, out var existingWriteTime) &&
|
|
existingWriteTime == sceneWriteTime)
|
|
{
|
|
return sceneAlias;
|
|
}
|
|
|
|
return string.Empty;
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (!string.IsNullOrWhiteSpace(existingAlias))
|
|
{
|
|
return existingAlias;
|
|
}
|
|
|
|
var eventHandler = GetEventHandlerCore();
|
|
var completion = eventHandler.BeginLoadSceneWait(sceneAlias);
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var shouldForceReload = forceReload || _scenePaths.ContainsKey(sceneAlias);
|
|
var scene = shouldForceReload
|
|
? _engine!.LoadSceneForce(scenePath, sceneAlias)
|
|
: _engine!.LoadScene(scenePath, sceneAlias);
|
|
|
|
_logService.Info(
|
|
$"Karisma {(shouldForceReload ? "LoadSceneForce" : "LoadScene")}() return={(scene is null ? "null" : "scene-handle")} alias={sceneAlias} path={scenePath}");
|
|
|
|
_scenes[sceneAlias] = scene ?? throw new InvalidOperationException($"Failed to load Karisma scene: {scenePath}");
|
|
_scenePaths[sceneAlias] = scenePath;
|
|
_sceneWriteTimes[sceneAlias] = sceneWriteTime;
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
|
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
|
if (result != eKResult.RESULT_SUCCESS)
|
|
{
|
|
throw new InvalidOperationException($"LoadScene failed for '{sceneAlias}': {result} ({(int)result})");
|
|
}
|
|
|
|
_logService.Info($"Karisma scene loaded: alias={sceneAlias} path={scenePath}");
|
|
return sceneAlias;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingLoadScene(sceneAlias, ex);
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
_scenes.Remove(sceneAlias);
|
|
_scenePaths.Remove(sceneAlias);
|
|
_sceneWriteTimes.Remove(sceneAlias);
|
|
}, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task ApplyValuesAsync(
|
|
string sceneAlias,
|
|
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesBeforeValue,
|
|
IReadOnlyDictionary<string, string> values,
|
|
IReadOnlyList<KarismaCounterNumberKeyUpdate> counterNumberKeys,
|
|
IReadOnlyList<KarismaChartCellUpdate> chartCellUpdates,
|
|
IReadOnlyList<KarismaPositionUpdate> positionUpdates,
|
|
IReadOnlyList<KarismaStyleColorUpdate> styleColorUpdates,
|
|
IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdatesAfterValue,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
Task<eKResult>? transactionCompletion = null;
|
|
var eventHandler = GetEventHandlerCore();
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scene = GetSceneCore(sceneAlias);
|
|
var counterNumberKeyObjectNames = counterNumberKeys
|
|
.Where(update => update.KeyIndex != 0 && !string.IsNullOrWhiteSpace(update.ObjectName))
|
|
.Select(update => update.ObjectName)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var counterSetValueObjectNames = counterNumberKeys
|
|
.Where(update => update.AllowSetValue && !string.IsNullOrWhiteSpace(update.ObjectName))
|
|
.Select(update => update.ObjectName)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
_engine!.BeginTransaction();
|
|
try
|
|
{
|
|
ApplyVisibilityUpdates(sceneAlias, scene, visibilityUpdatesBeforeValue);
|
|
|
|
foreach (var pair in values)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(pair.Key))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(pair.Key);
|
|
if (sceneObject is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (counterNumberKeyObjectNames.Contains(pair.Key) &&
|
|
!counterSetValueObjectNames.Contains(pair.Key) &&
|
|
sceneObject is IKACounter)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
sceneObject.SetValue(pair.Value ?? string.Empty);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning($"Karisma object update skipped: scene={sceneAlias} object={pair.Key} reason={ex.Message}");
|
|
}
|
|
}
|
|
|
|
foreach (var counterNumberKey in counterNumberKeys)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(counterNumberKey.ObjectName))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (counterNumberKey.KeyIndex == 0 && !counterNumberKey.AllowKeyZero)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var sceneObject = scene.GetObject(counterNumberKey.ObjectName);
|
|
if (sceneObject is not IKACounter counter)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} reason=object is not a counter");
|
|
continue;
|
|
}
|
|
|
|
counter.SetCounterNumberKey(counterNumberKey.KeyIndex, counterNumberKey.Number);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma counter update skipped: scene={sceneAlias} object={counterNumberKey.ObjectName} keyIndex={counterNumberKey.KeyIndex} reason={ex.Message}");
|
|
}
|
|
}
|
|
|
|
foreach (var chartCellUpdate in chartCellUpdates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(chartCellUpdate.ObjectName))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(chartCellUpdate.ObjectName);
|
|
if (sceneObject is not IKAChart chart)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} reason=object is not a chart");
|
|
continue;
|
|
}
|
|
|
|
chart.SetChartCellData(chartCellUpdate.Row, chartCellUpdate.Column, chartCellUpdate.Value);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma chart cell update skipped: scene={sceneAlias} object={chartCellUpdate.ObjectName} row={chartCellUpdate.Row} column={chartCellUpdate.Column} reason={ex.Message}");
|
|
}
|
|
}
|
|
|
|
foreach (var positionUpdate in positionUpdates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(positionUpdate.ObjectName))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(positionUpdate.ObjectName);
|
|
if (sceneObject is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (positionUpdate.KeyIndex >= 0)
|
|
{
|
|
sceneObject.SetPositionKey(
|
|
positionUpdate.KeyIndex,
|
|
positionUpdate.X,
|
|
positionUpdate.Y,
|
|
positionUpdate.Z,
|
|
positionUpdate.VectorType);
|
|
}
|
|
else
|
|
{
|
|
sceneObject.SetPosition(
|
|
positionUpdate.X,
|
|
positionUpdate.Y,
|
|
positionUpdate.Z,
|
|
positionUpdate.VectorType);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma position update skipped: scene={sceneAlias} object={positionUpdate.ObjectName} keyIndex={positionUpdate.KeyIndex} reason={ex.Message}");
|
|
}
|
|
}
|
|
|
|
foreach (var styleColorUpdate in styleColorUpdates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(styleColorUpdate.ObjectName))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(styleColorUpdate.ObjectName);
|
|
if (sceneObject is not IKAStyle style)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma style color update skipped: scene={sceneAlias} object={styleColorUpdate.ObjectName} reason=object does not implement IKAStyle");
|
|
continue;
|
|
}
|
|
|
|
style.SetStyleColor(
|
|
styleColorUpdate.StyleType,
|
|
styleColorUpdate.Order,
|
|
styleColorUpdate.R,
|
|
styleColorUpdate.G,
|
|
styleColorUpdate.B,
|
|
styleColorUpdate.A);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma style color update skipped: scene={sceneAlias} object={styleColorUpdate.ObjectName} style={styleColorUpdate.StyleType} order={styleColorUpdate.Order} reason={ex.Message}");
|
|
}
|
|
}
|
|
|
|
ApplyVisibilityUpdates(sceneAlias, scene, visibilityUpdatesAfterValue);
|
|
}
|
|
finally
|
|
{
|
|
transactionCompletion = eventHandler.BeginEndTransactionWait();
|
|
_engine!.EndTransaction();
|
|
}
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (transactionCompletion is not null)
|
|
{
|
|
var transactionResult = await WaitForKarismaResultAsync(transactionCompletion, cancellationToken).ConfigureAwait(false);
|
|
ThrowIfKarismaFailed(transactionResult, $"EndTransaction failed for scene={sceneAlias}");
|
|
}
|
|
|
|
await UpdateSceneTexturesAsync(sceneAlias, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingEndTransaction(ex);
|
|
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static DateTime ResolveSceneWriteTime(string scenePath)
|
|
{
|
|
try
|
|
{
|
|
return File.GetLastWriteTimeUtc(scenePath);
|
|
}
|
|
catch
|
|
{
|
|
return DateTime.MinValue;
|
|
}
|
|
}
|
|
|
|
private void ApplyVisibilityUpdates(string sceneAlias, KAScene scene, IReadOnlyList<KarismaVisibilityUpdate> visibilityUpdates)
|
|
{
|
|
foreach (var visibilityUpdate in visibilityUpdates)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(visibilityUpdate.ObjectName))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var sceneObject = scene.GetObject(visibilityUpdate.ObjectName);
|
|
if (sceneObject is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
sceneObject.SetVisible(visibilityUpdate.IsVisible ? 1 : 0);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning(
|
|
$"Karisma visibility update skipped: scene={sceneAlias} object={visibilityUpdate.ObjectName} visible={visibilityUpdate.IsVisible} reason={ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task UpdateSceneTexturesAsync(string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
var eventHandler = GetEventHandlerCore();
|
|
var completion = eventHandler.BeginUpdateTexturesWait(sceneAlias);
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
GetSceneCore(sceneAlias).UpdateTextures();
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
var result = await WaitForKarismaResultAsync(completion, cancellationToken).ConfigureAwait(false);
|
|
ThrowIfKarismaFailed(result, $"UpdateTextures failed for scene={sceneAlias}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingUpdateTextures(sceneAlias, ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task PrepareAsync(int outputChannelIndex, int layerNo, string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
var eventHandler = GetEventHandlerCore();
|
|
var completion = eventHandler.BeginScenePrepareWait(outputChannelIndex, layerNo);
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
GetScenePlayerCore(outputChannelIndex).Prepare(layerNo, GetSceneCore(sceneAlias));
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
var result = await WaitForKarismaResultAsync(completion, cancellationToken).ConfigureAwait(false);
|
|
ThrowIfKarismaFailed(result, $"Prepare failed for output={outputChannelIndex} layer={layerNo} scene={sceneAlias}");
|
|
|
|
// Give Karisma one render tick after Prepare before a mixed-preview screenshot is requested.
|
|
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingScenePrepare(outputChannelIndex, layerNo, ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public Task PlayAsync(int outputChannelIndex, int layerNo, bool cutIn, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
|
if (cutIn)
|
|
{
|
|
scenePlayer.CutIn(layerNo);
|
|
return;
|
|
}
|
|
|
|
scenePlayer.Play(layerNo);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task PlayOutAsync(int outputChannelIndex, int layerNo, bool cutOut, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var scenePlayer = GetScenePlayerCore(outputChannelIndex);
|
|
if (cutOut)
|
|
{
|
|
scenePlayer.CutOut(layerNo);
|
|
return;
|
|
}
|
|
|
|
scenePlayer.PlayOut(layerNo);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task ClearNextPreviewAsync(int outputChannelIndex, int layerNo, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
GetScenePlayerCore(outputChannelIndex).ClearNextPreview(layerNo);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public async Task SaveMixedPreviewImageAsync(
|
|
int outputChannelIndex,
|
|
int layerNo,
|
|
string fileName,
|
|
int width,
|
|
int height,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
throw new ArgumentException("Image file path is required.", nameof(fileName));
|
|
}
|
|
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
var eventHandler = GetEventHandlerCore();
|
|
var completion = eventHandler.BeginSaveMixedPreviewImageWait();
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var directory = Path.GetDirectoryName(fileName);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
GetScenePlayerCore(outputChannelIndex).SaveMixedPreviewImage(fileName, width, height);
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
|
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
|
|
|
if (result.Result != eKResult.RESULT_SUCCESS)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"SaveMixedPreviewImage failed for output={outputChannelIndex} layer={layerNo}: {result.Result} ({(int)result.Result})");
|
|
}
|
|
|
|
await WaitForFileAsync(fileName, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingSaveMixedPreviewImage(ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task SaveSceneImageAsync(
|
|
string sceneAlias,
|
|
string fileName,
|
|
int width,
|
|
int height,
|
|
int frame,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(sceneAlias))
|
|
{
|
|
throw new ArgumentException("Scene alias is required.", nameof(sceneAlias));
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(fileName))
|
|
{
|
|
throw new ArgumentException("Image file path is required.", nameof(fileName));
|
|
}
|
|
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
var eventHandler = GetEventHandlerCore();
|
|
var completion = eventHandler.BeginSaveSceneImageWait();
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
var directory = Path.GetDirectoryName(fileName);
|
|
if (!string.IsNullOrWhiteSpace(directory))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
}
|
|
|
|
GetSceneCore(sceneAlias).SaveSceneImage(fileName, width, height, frame);
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
|
var result = await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
|
|
|
if (result.Result != eKResult.RESULT_SUCCESS)
|
|
{
|
|
throw new InvalidOperationException($"SaveSceneImage failed for '{sceneAlias}': {result.Result} ({(int)result.Result})");
|
|
}
|
|
|
|
await WaitForFileAsync(fileName, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
eventHandler.CancelPendingSaveSceneImage(ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public Task UnloadSceneAsync(string sceneAlias, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
if (!_scenes.TryGetValue(sceneAlias, out var scene))
|
|
{
|
|
return;
|
|
}
|
|
|
|
scene.UnloadScene();
|
|
_scenes.Remove(sceneAlias);
|
|
_scenePaths.Remove(sceneAlias);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task TriggerAsync(int outputChannelIndex, int layerNo, string animationName, CancellationToken cancellationToken)
|
|
{
|
|
return _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
EnsureConnectedCore();
|
|
|
|
if (string.IsNullOrWhiteSpace(animationName))
|
|
{
|
|
return;
|
|
}
|
|
|
|
GetScenePlayerCore(outputChannelIndex).Trigger(layerNo, animationName);
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_autoReconnectEnabled = false;
|
|
_reconnectTimer.Dispose();
|
|
|
|
try
|
|
{
|
|
_dispatcher.InvokeAsync(() =>
|
|
{
|
|
foreach (var scene in _scenes.Values)
|
|
{
|
|
try
|
|
{
|
|
scene.UnloadScene();
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
if (_connected)
|
|
{
|
|
_engine?.Disconnect();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
_scenes.Clear();
|
|
_scenePaths.Clear();
|
|
_scenePlayers.Clear();
|
|
_engine = null;
|
|
_eventHandler = null;
|
|
_connected = false;
|
|
}, CancellationToken.None).GetAwaiter().GetResult();
|
|
}
|
|
finally
|
|
{
|
|
_dispatcher.Dispose();
|
|
}
|
|
}
|
|
|
|
private void EnsureConnectedCore()
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
if (_connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_engine ??= (IKAEngine)new KAEngineClass();
|
|
_eventHandler ??= new KarismaEventHandler(_logService, HandleConnect, HandleClose);
|
|
_autoReconnectEnabled = true;
|
|
|
|
var requested = _engine.Connect(_host, _port, _eventHandler);
|
|
_logService.Info($"Karisma Connect() return={(requested != 0 ? "TRUE" : "FALSE")} raw={requested} target={_host}:{_port}");
|
|
if (requested == 0)
|
|
{
|
|
throw new InvalidOperationException($"Karisma Async Engine connection request failed: {_host}:{_port}");
|
|
}
|
|
|
|
_connected = true;
|
|
_logService.Info($"Karisma Async Engine connect requested: {_host}:{_port}");
|
|
}
|
|
|
|
private void HandleConnect(int errorCode)
|
|
{
|
|
if (errorCode == 0)
|
|
{
|
|
_connected = true;
|
|
UpdateTcpConnectionState(true);
|
|
return;
|
|
}
|
|
|
|
_connected = false;
|
|
UpdateTcpConnectionState(false);
|
|
}
|
|
|
|
private void HandleClose(int errorCode)
|
|
{
|
|
_connected = false;
|
|
UpdateTcpConnectionState(false);
|
|
if (_eventHandler is not null)
|
|
{
|
|
var error = new IOException($"Karisma connection closed while waiting for a callback (errorCode={errorCode}).");
|
|
_eventHandler.CancelPendingConnect(error);
|
|
_eventHandler.CancelAllPendingLoadScenes(error);
|
|
_eventHandler.CancelPendingSaveSceneImage(error);
|
|
}
|
|
}
|
|
|
|
private void UpdateTcpConnectionState(bool isConnected)
|
|
{
|
|
if (_isTcpConnected == isConnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isTcpConnected = isConnected;
|
|
ConnectionChanged?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
|
|
private void OnReconnectTimerTick(object? state)
|
|
{
|
|
if (_disposed || !_autoReconnectEnabled || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.Exchange(ref _reconnectInProgress, 1) == 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_ = TryAutoReconnectAsync();
|
|
}
|
|
|
|
private async Task TryAutoReconnectAsync()
|
|
{
|
|
try
|
|
{
|
|
if (_disposed || !_autoReconnectEnabled || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
if (_disposed || _connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
EnsureConnectedCore();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logService.Warning($"Karisma auto reconnect attempt failed: {ex.Message}");
|
|
}
|
|
}, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
Interlocked.Exchange(ref _reconnectInProgress, 0);
|
|
}
|
|
}
|
|
|
|
private KAScene GetSceneCore(string sceneAlias)
|
|
{
|
|
if (_scenes.TryGetValue(sceneAlias, out var scene))
|
|
{
|
|
return scene;
|
|
}
|
|
|
|
throw new InvalidOperationException($"Karisma scene is not loaded: {sceneAlias}");
|
|
}
|
|
|
|
private KAScenePlayer GetScenePlayerCore(int outputChannelIndex)
|
|
{
|
|
if (_scenePlayers.TryGetValue(outputChannelIndex, out var scenePlayer))
|
|
{
|
|
return scenePlayer;
|
|
}
|
|
|
|
scenePlayer = _engine!.GetScenePlayer(outputChannelIndex)
|
|
?? throw new InvalidOperationException($"Karisma scene player is unavailable: output={outputChannelIndex}");
|
|
_scenePlayers[outputChannelIndex] = scenePlayer;
|
|
return scenePlayer;
|
|
}
|
|
|
|
private KarismaEventHandler GetEventHandlerCore()
|
|
{
|
|
return _eventHandler ?? throw new InvalidOperationException("Karisma event handler is unavailable.");
|
|
}
|
|
|
|
private static async Task<eKResult> WaitForKarismaResultAsync(Task<eKResult> completion, CancellationToken cancellationToken)
|
|
{
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
|
return await completion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
|
}
|
|
|
|
private static void ThrowIfKarismaFailed(eKResult result, string message)
|
|
{
|
|
if (result != eKResult.RESULT_SUCCESS)
|
|
{
|
|
throw new InvalidOperationException($"{message}: {result} ({(int)result})");
|
|
}
|
|
}
|
|
|
|
private async Task EnsureConnectedInternalAsync(CancellationToken cancellationToken)
|
|
{
|
|
Task<int>? connectCompletion = null;
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
ThrowIfDisposed();
|
|
|
|
if (_isTcpConnected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_engine ??= (IKAEngine)new KAEngineClass();
|
|
_eventHandler ??= new KarismaEventHandler(_logService, HandleConnect, HandleClose);
|
|
_autoReconnectEnabled = true;
|
|
connectCompletion = _eventHandler.BeginConnectWait();
|
|
|
|
if (_connected)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var requested = _engine.Connect(_host, _port, _eventHandler);
|
|
_logService.Info($"Karisma Connect() return={(requested != 0 ? "TRUE" : "FALSE")} raw={requested} target={_host}:{_port}");
|
|
if (requested == 0)
|
|
{
|
|
throw new InvalidOperationException($"Karisma Async Engine connection request failed: {_host}:{_port}");
|
|
}
|
|
|
|
_connected = true;
|
|
_logService.Info($"Karisma Async Engine connect requested: {_host}:{_port}");
|
|
}, cancellationToken).ConfigureAwait(false);
|
|
|
|
if (connectCompletion is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(AsyncOperationTimeout);
|
|
var errorCode = await connectCompletion.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
|
|
if (errorCode != 0)
|
|
{
|
|
throw new InvalidOperationException($"Karisma Async Engine OnConnect failed: errorCode={errorCode}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
if (_eventHandler is not null)
|
|
{
|
|
_eventHandler.CancelPendingConnect(ex);
|
|
}
|
|
|
|
try
|
|
{
|
|
await _dispatcher.InvokeAsync(() =>
|
|
{
|
|
_connected = false;
|
|
_isTcpConnected = false;
|
|
}, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static async Task WaitForFileAsync(string fileName, CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 0; attempt < 20; attempt++)
|
|
{
|
|
if (File.Exists(fileName))
|
|
{
|
|
var fileInfo = new FileInfo(fileName);
|
|
if (fileInfo.Length > 0)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
await Task.Delay(150, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
throw new IOException($"Saved scene image was not created: {fileName}");
|
|
}
|
|
|
|
private void ThrowIfDisposed()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
}
|
|
|
|
private sealed class StaDispatcher : IDisposable
|
|
{
|
|
private const uint WmApp = 0x8000;
|
|
private const uint WmDispatch = WmApp + 1;
|
|
|
|
private readonly ConcurrentQueue<Action> _queue = new();
|
|
private readonly Thread _thread;
|
|
private readonly ManualResetEventSlim _threadReady = new();
|
|
private bool _disposed;
|
|
private uint _threadId;
|
|
|
|
public StaDispatcher(string threadName)
|
|
{
|
|
_thread = new Thread(Run)
|
|
{
|
|
IsBackground = true,
|
|
Name = threadName
|
|
};
|
|
_thread.SetApartmentState(ApartmentState.STA);
|
|
_thread.Start();
|
|
_threadReady.Wait();
|
|
}
|
|
|
|
public Task InvokeAsync(Action action, CancellationToken cancellationToken)
|
|
{
|
|
return InvokeAsync(() =>
|
|
{
|
|
action();
|
|
return true;
|
|
}, cancellationToken);
|
|
}
|
|
|
|
public Task<T> InvokeAsync<T>(Func<T> action, CancellationToken cancellationToken)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_queue.Enqueue(() =>
|
|
{
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
completion.TrySetCanceled(cancellationToken);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
completion.TrySetResult(action());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
completion.TrySetException(ex);
|
|
}
|
|
});
|
|
|
|
if (!PostThreadMessage(_threadId, WmDispatch, UIntPtr.Zero, IntPtr.Zero))
|
|
{
|
|
throw new InvalidOperationException("Failed to signal Karisma STA dispatcher.");
|
|
}
|
|
|
|
return cancellationToken.CanBeCanceled
|
|
? completion.Task.WaitAsync(cancellationToken)
|
|
: completion.Task;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
PostThreadMessage(_threadId, WmQuit, UIntPtr.Zero, IntPtr.Zero);
|
|
_thread.Join();
|
|
_threadReady.Dispose();
|
|
}
|
|
|
|
private void Run()
|
|
{
|
|
PeekMessage(out _, IntPtr.Zero, 0, 0, 0);
|
|
_threadId = GetCurrentThreadId();
|
|
_threadReady.Set();
|
|
|
|
while (GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
|
{
|
|
if (message.message == WmDispatch)
|
|
{
|
|
DrainQueue();
|
|
continue;
|
|
}
|
|
|
|
TranslateMessage(ref message);
|
|
DispatchMessage(ref message);
|
|
}
|
|
|
|
DrainQueue();
|
|
}
|
|
|
|
private void DrainQueue()
|
|
{
|
|
while (_queue.TryDequeue(out var action))
|
|
{
|
|
action();
|
|
}
|
|
}
|
|
|
|
private const uint WmQuit = 0x0012;
|
|
|
|
[DllImport("kernel32.dll")]
|
|
private static extern uint GetCurrentThreadId();
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
private static extern bool PostThreadMessage(uint idThread, uint msg, UIntPtr wParam, IntPtr lParam);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern int GetMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool TranslateMessage([In] ref NativeMessage lpMsg);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern IntPtr DispatchMessage([In] ref NativeMessage lpMsg);
|
|
|
|
[DllImport("user32.dll")]
|
|
private static extern bool PeekMessage(out NativeMessage lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct NativeMessage
|
|
{
|
|
public IntPtr hwnd;
|
|
public uint message;
|
|
public UIntPtr wParam;
|
|
public IntPtr lParam;
|
|
public uint time;
|
|
public NativePoint pt;
|
|
public uint lPrivate;
|
|
}
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private struct NativePoint
|
|
{
|
|
public int x;
|
|
public int y;
|
|
}
|
|
}
|
|
}
|