mirror of
https://github.com/NotAKidoS/NAK_CVR_Mods.git
synced 2025-09-02 22:39:22 +00:00
Move many mods to Deprecated folder, fix spelling
This commit is contained in:
parent
5e822cec8d
commit
0042590aa6
539 changed files with 7475 additions and 3120 deletions
15
BetterContentLoading/BetterContentLoading.csproj
Normal file
15
BetterContentLoading/BetterContentLoading.csproj
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<RootNamespace>NAK.BetterContentLoading</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Reference Include="BTKUILib">
|
||||
<HintPath>..\.ManagedLibs\BTKUILib.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="TheClapper">
|
||||
<HintPath>..\.ManagedLibs\TheClapper.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
|
@ -0,0 +1,210 @@
|
|||
using ABI_RC.Core.InteractionSystem;
|
||||
using ABI_RC.Core.Networking.IO.Instancing;
|
||||
using ABI_RC.Core.Networking.IO.Social;
|
||||
using ABI_RC.Core.Player;
|
||||
using ABI_RC.Core.Savior;
|
||||
using ABI_RC.Core.Util;
|
||||
using ABI_RC.Systems.GameEventSystem;
|
||||
using NAK.BetterContentLoading.Queue;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
// Download world -> connect to instance -> receive all Avatar data
|
||||
// -> initial connection to instance -> receive all props data
|
||||
|
||||
// We receive Prop download data only after we have connected to the instance. Avatar data we seem to receive
|
||||
// prior to our initial connection event firing.
|
||||
|
||||
public class BetterDownloadManager
|
||||
{
|
||||
#region Singleton
|
||||
|
||||
private static BetterDownloadManager _instance;
|
||||
public static BetterDownloadManager Instance => _instance ??= new BetterDownloadManager();
|
||||
|
||||
#endregion Singleton
|
||||
|
||||
#region Constructor
|
||||
|
||||
private BetterDownloadManager()
|
||||
{
|
||||
_downloadProcessor = new DownloadProcessor();
|
||||
|
||||
_worldQueue = new WorldDownloadQueue(this); // Only one world at a time
|
||||
_avatarQueue = new AvatarDownloadQueue(this); // Up to 3 avatars at once
|
||||
_propQueue = new PropDownloadQueue(this); // Up to 2 props at once
|
||||
|
||||
// Set to 100MBs by default
|
||||
MaxDownloadBandwidth = 100 * 1024 * 1024;
|
||||
|
||||
CVRGameEventSystem.Instance.OnConnected.AddListener(_ =>
|
||||
{
|
||||
if (!Instances.IsReconnecting) OnInitialConnectionToInstance();
|
||||
});
|
||||
}
|
||||
|
||||
#endregion Constructor
|
||||
|
||||
#region Settings
|
||||
|
||||
/// Log debug messages
|
||||
public bool IsDebugEnabled { get; set; } = true;
|
||||
|
||||
/// Prioritize friends first in download queue
|
||||
public bool PrioritizeFriends { get; set; } = true;
|
||||
|
||||
/// Prioritize content closest to player first in download queue
|
||||
public bool PrioritizeDistance { get; set; } = true;
|
||||
public float PriorityDownloadDistance { get; set; } = 25f;
|
||||
|
||||
public int MaxDownloadBandwidth
|
||||
{
|
||||
get => _downloadProcessor.MaxDownloadBandwidth;
|
||||
set => _downloadProcessor.MaxDownloadBandwidth = value;
|
||||
}
|
||||
|
||||
#endregion Settings
|
||||
|
||||
private readonly DownloadProcessor _downloadProcessor;
|
||||
|
||||
private readonly AvatarDownloadQueue _avatarQueue;
|
||||
private readonly PropDownloadQueue _propQueue;
|
||||
private readonly WorldDownloadQueue _worldQueue;
|
||||
|
||||
#region Game Events
|
||||
|
||||
private void OnInitialConnectionToInstance()
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg("Initial connection established.");
|
||||
// await few seconds before chewing through the download queue, to allow for download priorities to be set
|
||||
// once we have received most of the data from the server
|
||||
}
|
||||
|
||||
#endregion Game Events
|
||||
|
||||
#region Public Queue Methods
|
||||
|
||||
public void QueueAvatarDownload(
|
||||
in DownloadInfo info,
|
||||
string playerId,
|
||||
CVRLoadingAvatarController loadingController)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing Avatar Download:\n{info.GetLogString()}\n" +
|
||||
$"PlayerId: {playerId}\n" +
|
||||
$"LoadingController: {loadingController}");
|
||||
}
|
||||
|
||||
_avatarQueue.QueueDownload(in info, playerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a prop download.
|
||||
/// </summary>
|
||||
/// <param name="info">The download info.</param>
|
||||
/// <param name="instanceId">The instance ID for the prop.</param>
|
||||
/// <param name="spawnerId">The user who spawned the prop.</param>
|
||||
public void QueuePropDownload(
|
||||
in DownloadInfo info,
|
||||
string instanceId,
|
||||
string spawnerId)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing Prop Download:\n{info.GetLogString()}\n" +
|
||||
$"InstanceId: {instanceId}\n" +
|
||||
$"SpawnerId: {spawnerId}");
|
||||
}
|
||||
|
||||
_propQueue.QueueDownload(in info, instanceId, spawnerId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a world download.
|
||||
/// </summary>
|
||||
/// <param name="info">Download info.</param>
|
||||
/// <param name="joinOnComplete">Whether to load into this world once downloaded.</param>
|
||||
/// <param name="isHomeRequested">Whether the home world is requested.</param>
|
||||
public void QueueWorldDownload(
|
||||
in DownloadInfo info,
|
||||
bool joinOnComplete,
|
||||
bool isHomeRequested)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing World Download:\n{info.GetLogString()}\n" +
|
||||
$"JoinOnComplete: {joinOnComplete}\n" +
|
||||
$"IsHomeRequested: {isHomeRequested}");
|
||||
}
|
||||
|
||||
_worldQueue.QueueDownload(in info, joinOnComplete, isHomeRequested);
|
||||
}
|
||||
|
||||
#endregion Public Queue Methods
|
||||
|
||||
#region Internal Methods
|
||||
|
||||
internal Task<bool> ProcessDownload(DownloadInfo info, Action<float> progressCallback = null)
|
||||
{
|
||||
return _downloadProcessor.ProcessDownload(info);
|
||||
}
|
||||
|
||||
#endregion Internal Methods
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
internal static bool TryGetPlayerEntity(string playerId, out CVRPlayerEntity playerEntity)
|
||||
{
|
||||
CVRPlayerEntity player = CVRPlayerManager.Instance.NetworkPlayers.Find(p => p.Uuid == playerId);
|
||||
if (player == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Player entity not found for ID: {playerId}");
|
||||
playerEntity = null;
|
||||
return false;
|
||||
}
|
||||
playerEntity = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryGetPropData(string instanceId, out CVRSyncHelper.PropData propData)
|
||||
{
|
||||
CVRSyncHelper.PropData prop = CVRSyncHelper.Props.Find(p => p.InstanceId == instanceId);
|
||||
if (prop == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Prop data not found for ID: {instanceId}");
|
||||
propData = null;
|
||||
return false;
|
||||
}
|
||||
propData = prop;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool IsPlayerLocal(string playerId)
|
||||
{
|
||||
return playerId == MetaPort.Instance.ownerId;
|
||||
}
|
||||
|
||||
internal static bool IsPlayerFriend(string playerId)
|
||||
{
|
||||
return Friends.FriendsWith(playerId);
|
||||
}
|
||||
|
||||
internal bool IsPlayerWithinPriorityDistance(CVRPlayerEntity player)
|
||||
{
|
||||
if (player.PuppetMaster == null) return false;
|
||||
return player.PuppetMaster.animatorManager.DistanceTo < PriorityDownloadDistance;
|
||||
}
|
||||
|
||||
internal bool IsPropWithinPriorityDistance(CVRSyncHelper.PropData prop)
|
||||
{
|
||||
Vector3 propPosition = new(prop.PositionX, prop.PositionY, prop.PositionZ);
|
||||
return Vector3.Distance(propPosition, PlayerSetup.Instance.GetPlayerPosition()) < PriorityDownloadDistance;
|
||||
}
|
||||
|
||||
#endregion Private Helper Methods
|
||||
}
|
63
BetterContentLoading/BetterContentLoading/DownloadInfo.cs
Normal file
63
BetterContentLoading/BetterContentLoading/DownloadInfo.cs
Normal file
|
@ -0,0 +1,63 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ABI_RC.Core.Networking.API.Responses;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public readonly struct DownloadInfo
|
||||
{
|
||||
public readonly string AssetId;
|
||||
public readonly string AssetUrl;
|
||||
public readonly string FileId;
|
||||
public readonly long FileSize;
|
||||
public readonly string FileKey;
|
||||
public readonly string FileHash;
|
||||
public readonly int CompatibilityVersion;
|
||||
public readonly int EncryptionAlgorithm;
|
||||
public readonly UgcTagsData TagsData;
|
||||
|
||||
public readonly string DownloadId;
|
||||
|
||||
public DownloadInfo(
|
||||
string assetId, string assetUrl, string fileId,
|
||||
long fileSize, string fileKey, string fileHash,
|
||||
int compatibilityVersion, int encryptionAlgorithm,
|
||||
UgcTagsData tagsData)
|
||||
{
|
||||
AssetId = assetId + "meow";
|
||||
AssetUrl = assetUrl;
|
||||
FileId = fileId;
|
||||
FileSize = fileSize;
|
||||
FileKey = fileKey;
|
||||
FileHash = fileHash;
|
||||
CompatibilityVersion = compatibilityVersion;
|
||||
EncryptionAlgorithm = encryptionAlgorithm;
|
||||
TagsData = tagsData;
|
||||
|
||||
using SHA256 sha = SHA256.Create();
|
||||
StringBuilder sb = new();
|
||||
sb.Append(assetId)
|
||||
.Append('|').Append(assetUrl)
|
||||
.Append('|').Append(fileId)
|
||||
.Append('|').Append(fileSize)
|
||||
.Append('|').Append(fileKey)
|
||||
.Append('|').Append(fileHash)
|
||||
.Append('|').Append(compatibilityVersion)
|
||||
.Append('|').Append(encryptionAlgorithm);
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
DownloadId = Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public string GetLogString() =>
|
||||
$"AssetId: {AssetId}\n" +
|
||||
$"DownloadId: {DownloadId}\n" +
|
||||
$"AssetUrl: {AssetUrl}\n" +
|
||||
$"FileId: {FileId}\n" +
|
||||
$"FileSize: {FileSize}\n" +
|
||||
$"FileKey: {FileKey}\n" +
|
||||
$"FileHash: {FileHash}\n" +
|
||||
$"CompatibilityVersion: {CompatibilityVersion}\n" +
|
||||
$"EncryptionAlgorithm: {EncryptionAlgorithm}";
|
||||
}
|
152
BetterContentLoading/BetterContentLoading/DownloadProcessor.cs
Normal file
152
BetterContentLoading/BetterContentLoading/DownloadProcessor.cs
Normal file
|
@ -0,0 +1,152 @@
|
|||
using System.Net.Http;
|
||||
using ABI_RC.Core;
|
||||
using ABI_RC.Core.IO.AssetManagement;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public class DownloadProcessor
|
||||
{
|
||||
private readonly HttpClient _client = new();
|
||||
private readonly SemaphoreSlim _bandwidthSemaphore = new(1);
|
||||
private long _bytesReadLastSecond;
|
||||
|
||||
private int _maxDownloadBandwidth = 10 * 1024 * 1024; // Default 10MB/s
|
||||
private const int MinBufferSize = 16384; // 16KB min buffer
|
||||
private const long ThrottleThreshold = 25 * 1024 * 1024; // 25MB threshold for throttling
|
||||
private const long KnownSizeDifference = 1000; // API reported size is 1000 bytes larger than actual content
|
||||
|
||||
public int MaxDownloadBandwidth
|
||||
{
|
||||
get => _maxDownloadBandwidth;
|
||||
set => _maxDownloadBandwidth = Math.Max(1024 * 1024, value);
|
||||
}
|
||||
|
||||
private int ActiveDownloads { get; set; }
|
||||
private int CurrentBandwidthPerDownload => MaxDownloadBandwidth / Math.Max(1, ActiveDownloads);
|
||||
|
||||
public int GetProgress(string downloadId) => _downloadProgress.GetValueOrDefault(downloadId, 0);
|
||||
private readonly Dictionary<string, int> _downloadProgress = new();
|
||||
|
||||
public async Task<bool> ProcessDownload(DownloadInfo downloadInfo)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await CacheManager.Instance.IsCachedFileUpToDate(
|
||||
downloadInfo.AssetId,
|
||||
downloadInfo.FileId,
|
||||
downloadInfo.FileHash))
|
||||
return true;
|
||||
|
||||
var filePath = CacheManager.Instance.GetCachePath(downloadInfo.AssetId, downloadInfo.FileId);
|
||||
if (!CVRTools.HasEnoughDiskSpace(filePath, downloadInfo.FileSize))
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Error($"Not enough disk space to download {downloadInfo.AssetId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheManager.Instance.EnsureCacheDirectoryExists(downloadInfo.AssetId);
|
||||
|
||||
bool success = false;
|
||||
Exception lastException = null;
|
||||
|
||||
for (int attempt = 0; attempt <= 1; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (attempt > 0)
|
||||
await Task.Delay(1000);
|
||||
|
||||
success = await DownloadWithBandwidthLimit(downloadInfo, filePath);
|
||||
if (success) break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success && lastException != null)
|
||||
BetterContentLoadingMod.Logger.Error($"Failed to download {downloadInfo.AssetId}: {lastException}");
|
||||
|
||||
_downloadProgress.Remove(downloadInfo.DownloadId);
|
||||
return success;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Error($"Error processing download for {downloadInfo.AssetId}: {ex}");
|
||||
_downloadProgress.Remove(downloadInfo.DownloadId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DownloadWithBandwidthLimit(DownloadInfo downloadInfo, string filePath)
|
||||
{
|
||||
await _bandwidthSemaphore.WaitAsync();
|
||||
try { ActiveDownloads++; }
|
||||
finally { _bandwidthSemaphore.Release(); }
|
||||
|
||||
string tempFilePath = filePath + ".download";
|
||||
try
|
||||
{
|
||||
using var response = await _client.GetAsync(downloadInfo.AssetUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return false;
|
||||
|
||||
var expectedContentSize = downloadInfo.FileSize - KnownSizeDifference;
|
||||
using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var fileStream = File.Open(tempFilePath, FileMode.Create);
|
||||
|
||||
var isEligibleForThrottle = downloadInfo.FileSize >= ThrottleThreshold;
|
||||
var totalBytesRead = 0L;
|
||||
byte[] buffer = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var lengthToRead = isEligibleForThrottle ? MinBufferSize : CurrentBandwidthPerDownload;
|
||||
if (buffer == null || lengthToRead != buffer.Length)
|
||||
buffer = new byte[lengthToRead];
|
||||
|
||||
var bytesRead = await stream.ReadAsync(buffer, 0, lengthToRead);
|
||||
if (bytesRead == 0) break;
|
||||
|
||||
if (isEligibleForThrottle)
|
||||
Interlocked.Add(ref _bytesReadLastSecond, bytesRead);
|
||||
|
||||
fileStream.Write(buffer, 0, bytesRead);
|
||||
totalBytesRead += bytesRead;
|
||||
|
||||
var progress = (int)(((float)totalBytesRead / expectedContentSize) * 100f);
|
||||
_downloadProgress[downloadInfo.DownloadId] = Math.Clamp(progress, 0, 100);
|
||||
}
|
||||
|
||||
fileStream.Flush();
|
||||
|
||||
var fileInfo = new FileInfo(tempFilePath);
|
||||
if (fileInfo.Length != expectedContentSize)
|
||||
return false;
|
||||
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
File.Move(tempFilePath, filePath);
|
||||
|
||||
CacheManager.Instance.QueuePrune();
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _bandwidthSemaphore.WaitAsync();
|
||||
try { ActiveDownloads--; }
|
||||
finally { _bandwidthSemaphore.Release(); }
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(tempFilePath))
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
namespace NAK.BetterContentLoading.Queue;
|
||||
|
||||
public class AvatarDownloadQueue : ContentDownloadQueueBase
|
||||
{
|
||||
public AvatarDownloadQueue(BetterDownloadManager manager) : base(manager, 3) { }
|
||||
|
||||
public void QueueDownload(in DownloadInfo info, string playerId, Action<string, string> onComplete = null)
|
||||
{
|
||||
float priority = CalculateAvatarPriority(in info, playerId);
|
||||
QueueDownload(in info, playerId, priority, onComplete);
|
||||
}
|
||||
|
||||
protected override async Task ProcessDownload(DownloadInfo info)
|
||||
{
|
||||
await base.ProcessDownload(info);
|
||||
}
|
||||
|
||||
protected override void OnDownloadProgress(string downloadId, float progress)
|
||||
{
|
||||
if (DownloadOwners.TryGetValue(downloadId, out var owners))
|
||||
{
|
||||
foreach (var playerId in owners)
|
||||
{
|
||||
if (BetterDownloadManager.IsPlayerLocal(playerId))
|
||||
{
|
||||
// Update loading progress on local player
|
||||
BetterContentLoadingMod.Logger.Msg($"Progress for local player ({playerId}): {progress:P}");
|
||||
continue;
|
||||
}
|
||||
if (BetterDownloadManager.TryGetPlayerEntity(playerId, out var player))
|
||||
{
|
||||
// Update loading progress on avatar controller if needed
|
||||
BetterContentLoadingMod.Logger.Msg($"Progress for {player.Username} ({playerId}): {progress:P}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override float RecalculatePriority(string downloadId)
|
||||
{
|
||||
if (!DownloadOwners.TryGetValue(downloadId, out var owners))
|
||||
return float.MaxValue;
|
||||
|
||||
float lowestPriority = float.MaxValue;
|
||||
DownloadInfo? downloadInfo = null;
|
||||
|
||||
// Find the queue item to get the DownloadInfo
|
||||
var queueItem = Queue.Find(x => x.Info.DownloadId == downloadId);
|
||||
if (queueItem.Info.AssetId != null)
|
||||
downloadInfo = queueItem.Info;
|
||||
|
||||
if (downloadInfo == null)
|
||||
return lowestPriority;
|
||||
|
||||
// Calculate priority for each owner and use the lowest (highest priority) value
|
||||
foreach (var playerId in owners)
|
||||
{
|
||||
var priority = CalculateAvatarPriority(downloadInfo.Value, playerId);
|
||||
lowestPriority = Math.Min(lowestPriority, priority);
|
||||
}
|
||||
|
||||
return lowestPriority;
|
||||
}
|
||||
|
||||
private float CalculateAvatarPriority(in DownloadInfo info, string playerId)
|
||||
{
|
||||
float priority = info.FileSize;
|
||||
|
||||
if (BetterDownloadManager.IsPlayerLocal(playerId))
|
||||
return 0f;
|
||||
|
||||
if (!BetterDownloadManager.TryGetPlayerEntity(playerId, out var player))
|
||||
return priority;
|
||||
|
||||
if (Manager.PrioritizeFriends && BetterDownloadManager.IsPlayerFriend(playerId))
|
||||
priority *= 0.5f;
|
||||
|
||||
if (Manager.PrioritizeDistance && Manager.IsPlayerWithinPriorityDistance(player))
|
||||
priority *= 0.75f;
|
||||
|
||||
return priority;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
namespace NAK.BetterContentLoading.Queue;
|
||||
|
||||
public abstract class ContentDownloadQueueBase
|
||||
{
|
||||
protected readonly struct QueueItem
|
||||
{
|
||||
public readonly DownloadInfo Info;
|
||||
public readonly float Priority;
|
||||
public readonly Action<string, string> OnComplete; // Callback with (downloadId, ownerId)
|
||||
|
||||
public QueueItem(DownloadInfo info, float priority, Action<string, string> onComplete)
|
||||
{
|
||||
Info = info;
|
||||
Priority = priority;
|
||||
OnComplete = onComplete;
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly List<QueueItem> Queue = new();
|
||||
private readonly HashSet<string> ActiveDownloads = new(); // By DownloadId
|
||||
protected readonly Dictionary<string, HashSet<string>> DownloadOwners = new(); // DownloadId -> Set of OwnerIds
|
||||
private readonly SemaphoreSlim DownloadSemaphore;
|
||||
protected readonly BetterDownloadManager Manager;
|
||||
|
||||
protected ContentDownloadQueueBase(BetterDownloadManager manager, int maxParallelDownloads)
|
||||
{
|
||||
Manager = manager;
|
||||
DownloadSemaphore = new SemaphoreSlim(maxParallelDownloads);
|
||||
}
|
||||
|
||||
protected void QueueDownload(in DownloadInfo info, string ownerId, float priority, Action<string, string> onComplete)
|
||||
{
|
||||
if (Manager.IsDebugEnabled)
|
||||
BetterContentLoadingMod.Logger.Msg($"Attempting to queue download for {info.AssetId} (DownloadId: {info.DownloadId})");
|
||||
|
||||
// Add to owners tracking
|
||||
if (!DownloadOwners.TryGetValue(info.DownloadId, out var owners))
|
||||
{
|
||||
owners = new HashSet<string>();
|
||||
DownloadOwners[info.DownloadId] = owners;
|
||||
}
|
||||
owners.Add(ownerId);
|
||||
|
||||
// If already downloading, just add the owner and callback
|
||||
if (ActiveDownloads.Contains(info.DownloadId))
|
||||
{
|
||||
if (Manager.IsDebugEnabled)
|
||||
BetterContentLoadingMod.Logger.Msg($"Already downloading {info.DownloadId}, added owner {ownerId}");
|
||||
return;
|
||||
}
|
||||
|
||||
DownloadInfo downloadInfo = info;
|
||||
var existingIndex = Queue.FindIndex(x => x.Info.DownloadId == downloadInfo.DownloadId);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
// Update priority if needed based on new owner
|
||||
var newPriority = RecalculatePriority(info.DownloadId);
|
||||
Queue[existingIndex] = new QueueItem(info, newPriority, onComplete);
|
||||
SortQueue();
|
||||
return;
|
||||
}
|
||||
|
||||
Queue.Add(new QueueItem(info, priority, onComplete));
|
||||
SortQueue();
|
||||
TryStartNextDownload();
|
||||
}
|
||||
|
||||
public void RemoveOwner(string downloadId, string ownerId)
|
||||
{
|
||||
if (!DownloadOwners.TryGetValue(downloadId, out var owners))
|
||||
return;
|
||||
|
||||
owners.Remove(ownerId);
|
||||
|
||||
if (owners.Count == 0)
|
||||
{
|
||||
// No more owners, cancel the download
|
||||
DownloadOwners.Remove(downloadId);
|
||||
CancelDownload(downloadId);
|
||||
}
|
||||
else if (!ActiveDownloads.Contains(downloadId))
|
||||
{
|
||||
// Still has owners and is queued, recalculate priority
|
||||
var existingIndex = Queue.FindIndex(x => x.Info.DownloadId == downloadId);
|
||||
if (existingIndex >= 0)
|
||||
{
|
||||
var item = Queue[existingIndex];
|
||||
var newPriority = RecalculatePriority(downloadId);
|
||||
Queue[existingIndex] = new QueueItem(item.Info, newPriority, item.OnComplete);
|
||||
SortQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual async void TryStartNextDownload()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Queue.Count == 0) return;
|
||||
|
||||
await DownloadSemaphore.WaitAsync();
|
||||
|
||||
if (Queue.Count > 0)
|
||||
{
|
||||
var item = Queue[0];
|
||||
Queue.RemoveAt(0);
|
||||
|
||||
// Double check we still have owners before starting download
|
||||
if (!DownloadOwners.TryGetValue(item.Info.DownloadId, out var owners) || owners.Count == 0)
|
||||
{
|
||||
DownloadSemaphore.Release();
|
||||
TryStartNextDownload();
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveDownloads.Add(item.Info.DownloadId);
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessDownload(item.Info);
|
||||
BetterContentLoadingMod.Logger.Msg($"Download completed for {item.Info.DownloadId}");
|
||||
|
||||
// Notify all owners of completion
|
||||
if (DownloadOwners.TryGetValue(item.Info.DownloadId, out owners))
|
||||
{
|
||||
foreach (var owner in owners)
|
||||
{
|
||||
item.OnComplete?.Invoke(item.Info.DownloadId, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Manager.IsDebugEnabled)
|
||||
BetterContentLoadingMod.Logger.Error($"Download failed for {item.Info.DownloadId}: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
ActiveDownloads.Remove(item.Info.DownloadId);
|
||||
DownloadSemaphore.Release();
|
||||
TryStartNextDownload();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DownloadSemaphore.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Error($"Error in TryStartNextDownload: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual async Task ProcessDownload(DownloadInfo info)
|
||||
{
|
||||
bool success = await Manager.ProcessDownload(info);
|
||||
|
||||
if (!success)
|
||||
throw new Exception($"Failed to download {info.AssetId}");
|
||||
}
|
||||
|
||||
protected abstract void OnDownloadProgress(string downloadId, float progress);
|
||||
|
||||
protected abstract float RecalculatePriority(string downloadId);
|
||||
|
||||
protected virtual void SortQueue()
|
||||
{
|
||||
Queue.Sort((a, b) => a.Priority.CompareTo(b.Priority));
|
||||
}
|
||||
|
||||
protected virtual void CancelDownload(string downloadId)
|
||||
{
|
||||
Queue.RemoveAll(x => x.Info.DownloadId == downloadId);
|
||||
if (ActiveDownloads.Remove(downloadId)) DownloadSemaphore.Release();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
using ABI_RC.Core.Util;
|
||||
|
||||
namespace NAK.BetterContentLoading.Queue;
|
||||
|
||||
public class PropDownloadQueue : ContentDownloadQueueBase
|
||||
{
|
||||
private readonly Dictionary<string, string> _ownerToSpawner = new(); // InstanceId -> SpawnerId
|
||||
|
||||
public PropDownloadQueue(BetterDownloadManager manager) : base(manager, 2) { }
|
||||
|
||||
public void QueueDownload(in DownloadInfo info, string instanceId, string spawnerId, Action<string, string> onComplete = null)
|
||||
{
|
||||
_ownerToSpawner[instanceId] = spawnerId;
|
||||
float priority = CalculatePropPriority(in info, instanceId, spawnerId);
|
||||
QueueDownload(in info, instanceId, priority, onComplete);
|
||||
}
|
||||
|
||||
protected override async Task ProcessDownload(DownloadInfo info)
|
||||
{
|
||||
await base.ProcessDownload(info);
|
||||
}
|
||||
|
||||
protected override void OnDownloadProgress(string downloadId, float progress)
|
||||
{
|
||||
if (DownloadOwners.TryGetValue(downloadId, out var owners))
|
||||
{
|
||||
foreach (var instanceId in owners)
|
||||
{
|
||||
if (BetterDownloadManager.TryGetPropData(instanceId, out CVRSyncHelper.PropData prop))
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg($"Progress for {prop.InstanceId}: {progress:P}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override float RecalculatePriority(string downloadId)
|
||||
{
|
||||
if (!DownloadOwners.TryGetValue(downloadId, out var owners))
|
||||
return float.MaxValue;
|
||||
|
||||
float lowestPriority = float.MaxValue;
|
||||
DownloadInfo? downloadInfo = null;
|
||||
|
||||
// Find the queue item to get the DownloadInfo
|
||||
var queueItem = Queue.Find(x => x.Info.DownloadId == downloadId);
|
||||
if (queueItem.Info.AssetId != null)
|
||||
downloadInfo = queueItem.Info;
|
||||
|
||||
if (downloadInfo == null)
|
||||
return lowestPriority;
|
||||
|
||||
// Calculate priority for each owner and use the lowest (highest priority) value
|
||||
foreach (var instanceId in owners)
|
||||
{
|
||||
if (_ownerToSpawner.TryGetValue(instanceId, out var spawnerId))
|
||||
{
|
||||
var priority = CalculatePropPriority(downloadInfo.Value, instanceId, spawnerId);
|
||||
lowestPriority = Math.Min(lowestPriority, priority);
|
||||
}
|
||||
}
|
||||
|
||||
return lowestPriority;
|
||||
}
|
||||
|
||||
private float CalculatePropPriority(in DownloadInfo info, string instanceId, string spawnerId)
|
||||
{
|
||||
float priority = info.FileSize;
|
||||
|
||||
if (!BetterDownloadManager.TryGetPropData(instanceId, out var prop))
|
||||
return priority;
|
||||
|
||||
if (Manager.PrioritizeFriends && BetterDownloadManager.IsPlayerFriend(spawnerId))
|
||||
priority *= 0.5f;
|
||||
|
||||
if (Manager.PrioritizeDistance && Manager.IsPropWithinPriorityDistance(prop))
|
||||
priority *= 0.75f;
|
||||
|
||||
return priority;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
namespace NAK.BetterContentLoading.Queue;
|
||||
|
||||
public class WorldDownloadQueue : ContentDownloadQueueBase
|
||||
{
|
||||
private readonly Queue<(DownloadInfo Info, bool JoinOnComplete, bool IsHomeRequest)> _backgroundQueue = new();
|
||||
private bool _isProcessingPriorityDownload;
|
||||
|
||||
public WorldDownloadQueue(BetterDownloadManager manager) : base(manager, 1) { }
|
||||
|
||||
public void QueueDownload(in DownloadInfo info, bool joinOnComplete, bool isHomeRequest, Action<string, string> onComplete = null)
|
||||
{
|
||||
if (joinOnComplete || isHomeRequest)
|
||||
{
|
||||
// Priority download - clear queue and download immediately
|
||||
Queue.Clear();
|
||||
_isProcessingPriorityDownload = true;
|
||||
QueueDownload(in info, info.DownloadId, 0f, onComplete);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Background download - add to background queue
|
||||
_backgroundQueue.Enqueue((info, false, false));
|
||||
if (!_isProcessingPriorityDownload)
|
||||
ProcessBackgroundQueue();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessDownload(DownloadInfo info)
|
||||
{
|
||||
await base.ProcessDownload(info);
|
||||
|
||||
if (_isProcessingPriorityDownload)
|
||||
{
|
||||
_isProcessingPriorityDownload = false;
|
||||
ProcessBackgroundQueue();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDownloadProgress(string downloadId, float progress)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg($"World download progress: {progress:P}");
|
||||
}
|
||||
|
||||
protected override float RecalculatePriority(string downloadId)
|
||||
{
|
||||
// For worlds, priority is based on whether it's a priority download and file size
|
||||
var queueItem = Queue.Find(x => x.Info.DownloadId == downloadId);
|
||||
return queueItem.Priority;
|
||||
}
|
||||
|
||||
private void ProcessBackgroundQueue()
|
||||
{
|
||||
while (_backgroundQueue.Count > 0)
|
||||
{
|
||||
(DownloadInfo info, var join, var home) = _backgroundQueue.Dequeue();
|
||||
QueueDownload(in info, info.DownloadId, info.FileSize, null);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void CancelDownload(string downloadId)
|
||||
{
|
||||
base.CancelDownload(downloadId);
|
||||
_backgroundQueue.Clear();
|
||||
_isProcessingPriorityDownload = false;
|
||||
}
|
||||
}
|
26
BetterContentLoading/BetterContentLoading/DownloadState.cs
Normal file
26
BetterContentLoading/BetterContentLoading/DownloadState.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public readonly struct DownloadState
|
||||
{
|
||||
public readonly string DownloadId;
|
||||
public readonly long BytesRead;
|
||||
public readonly long TotalBytes;
|
||||
public readonly int ProgressPercent;
|
||||
public readonly float BytesPerSecond;
|
||||
|
||||
public DownloadState(string downloadId, long bytesRead, long totalBytes, float bytesPerSecond)
|
||||
{
|
||||
DownloadId = downloadId;
|
||||
BytesRead = bytesRead;
|
||||
TotalBytes = totalBytes;
|
||||
BytesPerSecond = bytesPerSecond;
|
||||
ProgressPercent = (int)(((float)bytesRead / totalBytes) * 100f);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IDownloadMonitor
|
||||
{
|
||||
void OnDownloadProgress(DownloadState state);
|
||||
void OnDownloadStarted(string downloadId);
|
||||
void OnDownloadCompleted(string downloadId, bool success);
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
using JetBrains.Annotations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NAK.BetterContentLoading.Util;
|
||||
|
||||
[PublicAPI]
|
||||
public static class ThreadingHelper
|
||||
{
|
||||
private static readonly SynchronizationContext _mainThreadContext;
|
||||
|
||||
static ThreadingHelper()
|
||||
{
|
||||
_mainThreadContext = SynchronizationContext.Current;
|
||||
}
|
||||
|
||||
public static bool IsMainThread => SynchronizationContext.Current == _mainThreadContext;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an action on the main thread. Optionally waits for its completion.
|
||||
/// </summary>
|
||||
public static void RunOnMainThread(Action action, bool waitForCompletion = true, Action<Exception> onError = null)
|
||||
{
|
||||
if (SynchronizationContext.Current == _mainThreadContext)
|
||||
{
|
||||
// Already on the main thread
|
||||
TryExecute(action, onError);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (waitForCompletion)
|
||||
{
|
||||
ManualResetEvent done = new(false);
|
||||
_mainThreadContext.Post(_ =>
|
||||
{
|
||||
TryExecute(action, onError);
|
||||
done.Set();
|
||||
}, null);
|
||||
done.WaitOne(50000); // Block until action is completed
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fire and forget (don't wait for the action to complete)
|
||||
_mainThreadContext.Post(_ => TryExecute(action, onError), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs an action asynchronously on the main thread and returns a Task.
|
||||
/// </summary>
|
||||
public static Task RunOnMainThreadAsync(Action action, Action<Exception> onError = null)
|
||||
{
|
||||
if (SynchronizationContext.Current == _mainThreadContext)
|
||||
{
|
||||
// Already on the main thread
|
||||
TryExecute(action, onError);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_mainThreadContext.Post(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
TryExecute(action, onError);
|
||||
tcs.SetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.SetException(ex);
|
||||
}
|
||||
}, null);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a task on a background thread with cancellation support.
|
||||
/// </summary>
|
||||
public static async Task RunOffMainThreadAsync(Action action, CancellationToken cancellationToken, Action<Exception> onError = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
TryExecute(action, onError);
|
||||
}, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.LogWarning("Task was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for error handling.
|
||||
/// </summary>
|
||||
private static void TryExecute(Action action, Action<Exception> onError)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onError?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
}
|
237
BetterContentLoading/DownloadManager/DownloadManager.Core.cs
Normal file
237
BetterContentLoading/DownloadManager/DownloadManager.Core.cs
Normal file
|
@ -0,0 +1,237 @@
|
|||
using ABI_RC.Core.InteractionSystem;
|
||||
using ABI_RC.Core.Networking.API.Responses;
|
||||
using ABI_RC.Core.Networking.IO.Social;
|
||||
using ABI_RC.Core.Savior;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager
|
||||
{
|
||||
#region Singleton
|
||||
private static DownloadManager _instance;
|
||||
public static DownloadManager Instance => _instance ??= new DownloadManager();
|
||||
#endregion
|
||||
|
||||
private DownloadManager()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#region Settings
|
||||
public bool IsDebugEnabled { get; set; } = true;
|
||||
public bool PrioritizeFriends { get; set; } = true;
|
||||
public bool PrioritizeDistance { get; set; } = true;
|
||||
public float PriorityDownloadDistance { get; set; } = 25f;
|
||||
public int MaxConcurrentDownloads { get; set; } = 3;
|
||||
public int MaxDownloadBandwidth { get; set; } = 100 * 1024 * 1024; // 100MB default
|
||||
private const int THROTTLE_THRESHOLD = 25 * 1024 * 1024; // 25MB threshold for throttling
|
||||
|
||||
public long MaxAvatarDownloadSize { get; set; } = 25 * 1024 * 1024; // 25MB default
|
||||
public long MaxPropDownloadSize { get; set; } = 25 * 1024 * 1024; // 25MB default
|
||||
|
||||
#endregion Settings
|
||||
|
||||
#region State
|
||||
|
||||
// priority -> downloadtask
|
||||
private readonly SortedList<float, DownloadTask> _downloadQueue = new();
|
||||
|
||||
// downloadId -> downloadtask
|
||||
private readonly Dictionary<string, DownloadTask> _cachedDownloads = new();
|
||||
|
||||
#endregion State
|
||||
|
||||
#region Public Queue Methods
|
||||
|
||||
public void QueueAvatarDownload(
|
||||
in DownloadInfo info,
|
||||
string playerId,
|
||||
CVRLoadingAvatarController loadingController)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing Avatar Download:\n{info.GetLogString()}\n" +
|
||||
$"PlayerId: {playerId}\n" +
|
||||
$"LoadingController: {loadingController}");
|
||||
}
|
||||
|
||||
if (!ShouldQueueAvatarDownload(info, playerId))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Avatar Download not eligible: {info.DownloadId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_cachedDownloads.TryGetValue(info.DownloadId, out DownloadTask cachedDownload))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Avatar Download already queued: {info.DownloadId}");
|
||||
cachedDownload.AddInstantiationTarget(playerId);
|
||||
cachedDownload.BasePriority = CalculatePriority(cachedDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
DownloadTask task = new()
|
||||
{
|
||||
Info = info,
|
||||
Type = DownloadTaskType.Avatar
|
||||
};
|
||||
task.AddInstantiationTarget(playerId);
|
||||
task.BasePriority = CalculatePriority(task);
|
||||
}
|
||||
|
||||
private bool ShouldQueueAvatarDownload(
|
||||
in DownloadInfo info,
|
||||
string playerId)
|
||||
{
|
||||
// Check if content is incompatible or banned
|
||||
if (info.TagsData.Incompatible || info.TagsData.AdminBanned)
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Avatar is incompatible or banned");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if player is blocked
|
||||
if (MetaPort.Instance.blockedUserIds.Contains(playerId))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Player is blocked: {playerId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if mature content is disabled
|
||||
UgcTagsData tags = info.TagsData;
|
||||
if (!MetaPort.Instance.matureContentAllowed && (tags.Gore || tags.Nudity))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Mature content is disabled");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (info.FileSize > MaxAvatarDownloadSize)
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Avatar Download too large: {info.FileSize} > {MaxAvatarDownloadSize}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get visibility status for the avatar
|
||||
// ForceHidden means player avatar or avatar itself is forced off.
|
||||
// ForceShown will bypass all checks and return true.
|
||||
MetaPort.Instance.SelfModerationManager.GetAvatarVisibility(playerId, info.AssetId,
|
||||
out bool wasForceHidden, out bool wasForceShown);
|
||||
|
||||
if (!wasForceHidden)
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Avatar is not visible either because player avatar or avatar itself is forced off");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!wasForceShown)
|
||||
{
|
||||
// Check content filter settings if not force shown
|
||||
CVRSettings settings = MetaPort.Instance.settings;
|
||||
bool isLocalPlayer = playerId == MetaPort.Instance.ownerId;
|
||||
bool isFriend = Friends.FriendsWith(playerId);
|
||||
bool CheckFilterSettings(string settingName)
|
||||
{
|
||||
int settingVal = settings.GetSettingInt(settingName);
|
||||
switch (settingVal)
|
||||
{
|
||||
// Only Self
|
||||
case 0 when !isLocalPlayer:
|
||||
// Only Friends
|
||||
case 1 when !isFriend:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!CheckFilterSettings("ContentFilterVisibility")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterNudity")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterGore")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterSuggestive")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterFlashingColors")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterFlashingLights")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterScreenEffects")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterExtremelyBright")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterViolence")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterJumpscare")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterExcessivelyHuge")) return false;
|
||||
if (!CheckFilterSettings("ContentFilterExcessivelySmall")) return false;
|
||||
}
|
||||
|
||||
// All eligibility checks passed
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a prop download.
|
||||
/// </summary>
|
||||
/// <param name="info">The download info.</param>
|
||||
/// <param name="instanceId">The instance ID for the prop.</param>
|
||||
/// <param name="spawnerId">The user who spawned the prop.</param>
|
||||
public void QueuePropDownload(
|
||||
in DownloadInfo info,
|
||||
string instanceId,
|
||||
string spawnerId)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing Prop Download:\n{info.GetLogString()}\n" +
|
||||
$"InstanceId: {instanceId}\n" +
|
||||
$"SpawnerId: {spawnerId}");
|
||||
}
|
||||
|
||||
if (_cachedDownloads.TryGetValue(info.DownloadId, out DownloadTask cachedDownload))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"Prop Download already queued: {info.DownloadId}");
|
||||
cachedDownload.AddInstantiationTarget(instanceId);
|
||||
cachedDownload.BasePriority = CalculatePriority(cachedDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
DownloadTask task = new()
|
||||
{
|
||||
Info = info,
|
||||
Type = DownloadTaskType.Prop
|
||||
};
|
||||
task.AddInstantiationTarget(instanceId);
|
||||
task.BasePriority = CalculatePriority(task);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a world download.
|
||||
/// </summary>
|
||||
/// <param name="info">Download info.</param>
|
||||
/// <param name="joinOnComplete">Whether to load into this world once downloaded.</param>
|
||||
/// <param name="isHomeRequested">Whether the home world is requested.</param>
|
||||
public void QueueWorldDownload(
|
||||
in DownloadInfo info,
|
||||
bool joinOnComplete,
|
||||
bool isHomeRequested)
|
||||
{
|
||||
if (IsDebugEnabled)
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Msg(
|
||||
$"Queuing World Download:\n{info.GetLogString()}\n" +
|
||||
$"JoinOnComplete: {joinOnComplete}\n" +
|
||||
$"IsHomeRequested: {isHomeRequested}");
|
||||
}
|
||||
|
||||
if (_cachedDownloads.TryGetValue(info.DownloadId, out DownloadTask cachedDownload))
|
||||
{
|
||||
if (IsDebugEnabled) BetterContentLoadingMod.Logger.Msg($"World Download already queued: {info.DownloadId}");
|
||||
cachedDownload.BasePriority = CalculatePriority(cachedDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
DownloadTask task = new()
|
||||
{
|
||||
Info = info,
|
||||
Type = DownloadTaskType.World
|
||||
};
|
||||
task.BasePriority = CalculatePriority(task);
|
||||
}
|
||||
|
||||
#endregion Public Queue Methods
|
||||
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
using ABI_RC.Core.Networking.IO.Social;
|
||||
using ABI_RC.Core.Player;
|
||||
using ABI_RC.Core.Savior;
|
||||
using ABI_RC.Core.Util;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager
|
||||
{
|
||||
internal static bool TryGetPlayerEntity(string playerId, out CVRPlayerEntity playerEntity)
|
||||
{
|
||||
CVRPlayerEntity player = CVRPlayerManager.Instance.NetworkPlayers.Find(p => p.Uuid == playerId);
|
||||
if (player == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Player entity not found for ID: {playerId}");
|
||||
playerEntity = null;
|
||||
return false;
|
||||
}
|
||||
playerEntity = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryGetPropData(string instanceId, out CVRSyncHelper.PropData propData)
|
||||
{
|
||||
CVRSyncHelper.PropData prop = CVRSyncHelper.Props.Find(p => p.InstanceId == instanceId);
|
||||
if (prop == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Prop data not found for ID: {instanceId}");
|
||||
propData = null;
|
||||
return false;
|
||||
}
|
||||
propData = prop;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPlayerLocal(string playerId)
|
||||
{
|
||||
return playerId == MetaPort.Instance.ownerId;
|
||||
}
|
||||
|
||||
private static bool IsPlayerFriend(string playerId)
|
||||
{
|
||||
return Friends.FriendsWith(playerId);
|
||||
}
|
||||
|
||||
private bool IsPlayerWithinPriorityDistance(CVRPlayerEntity player)
|
||||
{
|
||||
if (player.PuppetMaster == null) return false;
|
||||
return player.PuppetMaster.animatorManager.DistanceTo < PriorityDownloadDistance;
|
||||
}
|
||||
|
||||
internal bool IsPropWithinPriorityDistance(CVRSyncHelper.PropData prop)
|
||||
{
|
||||
Vector3 propPosition = new(prop.PositionX, prop.PositionY, prop.PositionZ);
|
||||
return Vector3.Distance(propPosition, PlayerSetup.Instance.GetPlayerPosition()) < PriorityDownloadDistance;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
using ABI_RC.Core.Player;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager
|
||||
{
|
||||
private float CalculatePriority(DownloadTask task)
|
||||
{
|
||||
return task.Type switch
|
||||
{
|
||||
DownloadTaskType.Avatar => CalculateAvatarPriority(task),
|
||||
// DownloadTaskType.Prop => CalculatePropPriority(task2),
|
||||
// DownloadTaskType.World => CalculateWorldPriority(task2),
|
||||
_ => task.Info.FileSize
|
||||
};
|
||||
}
|
||||
|
||||
private float CalculateAvatarPriority(DownloadTask task)
|
||||
{
|
||||
float priority = task.Info.FileSize;
|
||||
|
||||
foreach (string target in task.InstantiationTargets)
|
||||
{
|
||||
if (IsPlayerLocal(target)) return 0f;
|
||||
|
||||
if (!TryGetPlayerEntity(target, out CVRPlayerEntity player))
|
||||
return priority;
|
||||
|
||||
if (PrioritizeFriends && IsPlayerFriend(target))
|
||||
priority *= 0.5f;
|
||||
|
||||
if (PrioritizeDistance && IsPlayerWithinPriorityDistance(player))
|
||||
priority *= 0.75f;
|
||||
}
|
||||
|
||||
return priority;
|
||||
}
|
||||
}
|
33
BetterContentLoading/DownloadManager/DownloadTask.Main.cs
Normal file
33
BetterContentLoading/DownloadManager/DownloadTask.Main.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadTask
|
||||
{
|
||||
public DownloadInfo Info { get; set; }
|
||||
public DownloadTaskStatus Status { get; set; }
|
||||
public DownloadTaskType Type { get; set; }
|
||||
|
||||
public float BasePriority { get; set; }
|
||||
public float CurrentPriority => BasePriority * (1 + Progress / 100f);
|
||||
|
||||
public long BytesRead { get; set; }
|
||||
public int Progress { get; set; }
|
||||
|
||||
/// The avatar/prop instances that wish to utilize this bundle.
|
||||
public List<string> InstantiationTargets { get; } = new();
|
||||
|
||||
public void AddInstantiationTarget(string target)
|
||||
{
|
||||
if (InstantiationTargets.Contains(target))
|
||||
return;
|
||||
|
||||
InstantiationTargets.Add(target);
|
||||
}
|
||||
|
||||
public void RemoveInstantiationTarget(string target)
|
||||
{
|
||||
if (!InstantiationTargets.Contains(target))
|
||||
return;
|
||||
|
||||
InstantiationTargets.Remove(target);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadTask
|
||||
{
|
||||
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public class ConcurrentPriorityQueue<TElement, TPriority> where TPriority : IComparable<TPriority>
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly SortedDictionary<TPriority, Queue<TElement>> _queues = new();
|
||||
|
||||
public void Enqueue(TElement item, TPriority priority)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_queues.TryGetValue(priority, out var queue))
|
||||
{
|
||||
queue = new Queue<TElement>();
|
||||
_queues[priority] = queue;
|
||||
}
|
||||
queue.Enqueue(item);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDequeue(out TElement item, out TPriority priority)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_queues.Count == 0)
|
||||
{
|
||||
item = default;
|
||||
priority = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var firstQueue = _queues.First();
|
||||
priority = firstQueue.Key;
|
||||
var queue = firstQueue.Value;
|
||||
item = queue.Dequeue();
|
||||
|
||||
if (queue.Count == 0)
|
||||
_queues.Remove(priority);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
private void StartBandwidthMonitor()
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
Interlocked.Exchange(ref _bytesReadLastSecond, 0);
|
||||
Interlocked.Exchange(ref _completedDownloads, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int ComputeUsableBandwidthPerDownload()
|
||||
{
|
||||
var activeCount = _activeDownloads.Count;
|
||||
if (activeCount == 0) return MaxDownloadBandwidth;
|
||||
return MaxDownloadBandwidth / activeCount;
|
||||
}
|
||||
}
|
126
BetterContentLoading/DownloadManager2/DownloadManager.Core.cs
Normal file
126
BetterContentLoading/DownloadManager2/DownloadManager.Core.cs
Normal file
|
@ -0,0 +1,126 @@
|
|||
using System.Collections.Concurrent;
|
||||
using ABI_RC.Core;
|
||||
using ABI_RC.Core.IO;
|
||||
using ABI_RC.Core.IO.AssetManagement;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
#region Singleton
|
||||
private static DownloadManager2 _instance;
|
||||
public static DownloadManager2 Instance => _instance ??= new DownloadManager2();
|
||||
#endregion
|
||||
|
||||
#region Settings
|
||||
public bool IsDebugEnabled { get; set; } = true;
|
||||
public bool PrioritizeFriends { get; set; } = true;
|
||||
public bool PrioritizeDistance { get; set; } = true;
|
||||
public float PriorityDownloadDistance { get; set; } = 25f;
|
||||
public int MaxConcurrentDownloads { get; set; } = 5;
|
||||
public int MaxDownloadBandwidth { get; set; } // 100MB default
|
||||
private const int THROTTLE_THRESHOLD = 25 * 1024 * 1024; // 25MB threshold for throttling
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private readonly ConcurrentDictionary<string, DownloadTask2> _activeDownloads;
|
||||
private readonly ConcurrentPriorityQueue<DownloadTask2, float> _queuedDownloads;
|
||||
private readonly ConcurrentDictionary<string, DownloadTask2> _completedDownloads;
|
||||
private readonly object _downloadLock = new();
|
||||
private long _bytesReadLastSecond;
|
||||
#endregion
|
||||
|
||||
private DownloadManager2()
|
||||
{
|
||||
_activeDownloads = new ConcurrentDictionary<string, DownloadTask2>();
|
||||
_queuedDownloads = new ConcurrentPriorityQueue<DownloadTask2, float>();
|
||||
MaxDownloadBandwidth = 100 * 1024 * 1024;
|
||||
StartBandwidthMonitor();
|
||||
}
|
||||
|
||||
public async Task<bool> QueueAvatarDownload(DownloadInfo info, string playerId)
|
||||
{
|
||||
if (await ValidateAndCheckCache(info))
|
||||
return true;
|
||||
|
||||
DownloadTask2 task2 = GetOrCreateDownloadTask(info, DownloadTaskType.Avatar);
|
||||
task2.AddTarget(playerId);
|
||||
QueueDownload(task2);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> QueuePropDownload(DownloadInfo info, string instanceId, string spawnerId)
|
||||
{
|
||||
if (await ValidateAndCheckCache(info))
|
||||
return true;
|
||||
|
||||
DownloadTask2 task2 = GetOrCreateDownloadTask(info, DownloadTaskType.Prop);
|
||||
task2.AddTarget(instanceId, spawnerId);
|
||||
QueueDownload(task2);
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> QueueWorldDownload(DownloadInfo info, bool loadOnComplete)
|
||||
{
|
||||
if (await ValidateAndCheckCache(info))
|
||||
return true;
|
||||
|
||||
DownloadTask2 task2 = GetOrCreateDownloadTask(info, DownloadTaskType.World, loadOnComplete);
|
||||
QueueDownload(task2);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> ValidateAndCheckCache(DownloadInfo info)
|
||||
{
|
||||
// Check if already cached and up to date
|
||||
if (await CacheManager.Instance.IsCachedFileUpToDate(
|
||||
info.AssetId,
|
||||
info.FileId,
|
||||
info.FileHash))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate disk space
|
||||
var filePath = CacheManager.Instance.GetCachePath(info.AssetId, info.FileId);
|
||||
if (!CVRTools.HasEnoughDiskSpace(filePath, info.FileSize))
|
||||
{
|
||||
BetterContentLoadingMod.Logger.Error($"Not enough disk space to download {info.AssetId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
CacheManager.Instance.EnsureCacheDirectoryExists(info.AssetId);
|
||||
return false;
|
||||
}
|
||||
|
||||
private DownloadTask2 GetOrCreateDownloadTask(DownloadInfo info, DownloadTaskType type, bool loadOnComplete = false)
|
||||
{
|
||||
// Check if task already exists in active downloads
|
||||
if (_activeDownloads.TryGetValue(info.DownloadId, out var activeTask))
|
||||
return activeTask;
|
||||
|
||||
// Check if task exists in queued downloads
|
||||
var queuedTask = _queuedDownloads.TryFind(t => t.Info.DownloadId == info.DownloadId);
|
||||
if (queuedTask != null)
|
||||
return queuedTask;
|
||||
|
||||
// Create new task
|
||||
var cachePath = CacheManager.Instance.GetCachePath(info.AssetId, info.FileId);
|
||||
return new DownloadTask2(info, cachePath, type, loadOnComplete);
|
||||
}
|
||||
|
||||
public bool TryFindTask(string downloadId, out DownloadTask2 task2)
|
||||
{
|
||||
return _activeDownloads.TryGetValue(downloadId, out task2) ||
|
||||
_completedDownloads.TryGetValue(downloadId, out task2) ||
|
||||
TryFindQueuedTask(downloadId, out task2);
|
||||
}
|
||||
|
||||
private bool TryFindQueuedTask(string downloadId, out DownloadTask2 task2)
|
||||
{
|
||||
task2 = _queuedDownloads.UnorderedItems
|
||||
.FirstOrDefault(x => x.Element.Info.DownloadId == downloadId).Element;
|
||||
return task2 != null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
using ABI_RC.Core.Networking.IO.Social;
|
||||
using ABI_RC.Core.Player;
|
||||
using ABI_RC.Core.Savior;
|
||||
using ABI_RC.Core.Util;
|
||||
using UnityEngine;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
internal static bool TryGetPlayerEntity(string playerId, out CVRPlayerEntity playerEntity)
|
||||
{
|
||||
CVRPlayerEntity player = CVRPlayerManager.Instance.NetworkPlayers.Find(p => p.Uuid == playerId);
|
||||
if (player == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Player entity not found for ID: {playerId}");
|
||||
playerEntity = null;
|
||||
return false;
|
||||
}
|
||||
playerEntity = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryGetPropData(string instanceId, out CVRSyncHelper.PropData propData)
|
||||
{
|
||||
CVRSyncHelper.PropData prop = CVRSyncHelper.Props.Find(p => p.InstanceId == instanceId);
|
||||
if (prop == null)
|
||||
{
|
||||
// BetterContentLoadingMod.Logger.Error($"Prop data not found for ID: {instanceId}");
|
||||
propData = null;
|
||||
return false;
|
||||
}
|
||||
propData = prop;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPlayerLocal(string playerId)
|
||||
{
|
||||
return playerId == MetaPort.Instance.ownerId;
|
||||
}
|
||||
|
||||
private static bool IsPlayerFriend(string playerId)
|
||||
{
|
||||
return Friends.FriendsWith(playerId);
|
||||
}
|
||||
|
||||
private bool IsPlayerWithinPriorityDistance(CVRPlayerEntity player)
|
||||
{
|
||||
if (player.PuppetMaster == null) return false;
|
||||
return player.PuppetMaster.animatorManager.DistanceTo < PriorityDownloadDistance;
|
||||
}
|
||||
|
||||
internal bool IsPropWithinPriorityDistance(CVRSyncHelper.PropData prop)
|
||||
{
|
||||
Vector3 propPosition = new(prop.PositionX, prop.PositionY, prop.PositionZ);
|
||||
return Vector3.Distance(propPosition, PlayerSetup.Instance.GetPlayerPosition()) < PriorityDownloadDistance;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
using ABI_RC.Core.IO;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
private float CalculatePriority(DownloadTask2 task2)
|
||||
{
|
||||
return task2.Type switch
|
||||
{
|
||||
DownloadTaskType.Avatar => CalculateAvatarPriority(task2),
|
||||
DownloadTaskType.Prop => CalculatePropPriority(task2),
|
||||
DownloadTaskType.World => CalculateWorldPriority(task2),
|
||||
_ => task2.Info.FileSize
|
||||
};
|
||||
}
|
||||
|
||||
private float CalculateAvatarPriority(DownloadTask2 task2)
|
||||
{
|
||||
float priority = task2.Info.FileSize;
|
||||
|
||||
if (IsPlayerLocal(task2.PlayerId))
|
||||
return 0f;
|
||||
|
||||
if (!TryGetPlayerEntity(task2.PlayerId, out var player))
|
||||
return priority;
|
||||
|
||||
if (PrioritizeFriends && IsPlayerFriend(task2.PlayerId))
|
||||
priority *= 0.5f;
|
||||
|
||||
if (PrioritizeDistance && IsPlayerWithinPriorityDistance(player))
|
||||
priority *= 0.75f;
|
||||
|
||||
// Factor in download progress
|
||||
priority *= (1 + task2.Progress / 100f);
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
private float CalculatePropPriority(DownloadTask2 task2)
|
||||
{
|
||||
float priority = task2.Info.FileSize;
|
||||
|
||||
if (IsPlayerLocal(task2.PlayerId))
|
||||
return 0f;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using ABI_RC.Core.IO;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
private async Task ProcessDownload(DownloadTask2 task2)
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
|
||||
// Set up resume headers if we have a resume token
|
||||
if (!string.IsNullOrEmpty(task2.ResumeToken))
|
||||
{
|
||||
client.DefaultRequestHeaders.Range = new RangeHeaderValue(task2.BytesRead, null);
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync(task2.Info.AssetUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
using var dataStream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
// Open file in append mode if resuming, otherwise create new
|
||||
using var fileStream = new FileStream(
|
||||
task2.TargetPath,
|
||||
string.IsNullOrEmpty(task2.ResumeToken) ? FileMode.Create : FileMode.Append,
|
||||
FileAccess.Write);
|
||||
|
||||
bool isEligibleForThrottle = task2.Info.FileSize > THROTTLE_THRESHOLD;
|
||||
var stopwatch = new Stopwatch();
|
||||
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
if (task2.Status == DownloadTaskStatus.Paused)
|
||||
{
|
||||
task2.ResumeToken = GenerateResumeToken(task2);
|
||||
await fileStream.FlushAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (task2.Status != DownloadTaskStatus.Downloading)
|
||||
{
|
||||
HandleCancellation(task2, dataStream, fileStream);
|
||||
return;
|
||||
}
|
||||
|
||||
stopwatch.Restart();
|
||||
var lengthToRead = isEligibleForThrottle ?
|
||||
ComputeUsableBandwidthPerDownload() :
|
||||
16384;
|
||||
|
||||
var buffer = new byte[lengthToRead];
|
||||
bytesRead = await dataStream.ReadAsync(buffer, 0, lengthToRead);
|
||||
|
||||
if (isEligibleForThrottle)
|
||||
Interlocked.Add(ref _bytesReadLastSecond, bytesRead);
|
||||
|
||||
await fileStream.WriteAsync(buffer, 0, bytesRead);
|
||||
UpdateProgress(task2, bytesRead);
|
||||
|
||||
} while (bytesRead > 0);
|
||||
|
||||
CompleteDownload(task2);
|
||||
}
|
||||
|
||||
private string GenerateResumeToken(DownloadTask2 task2)
|
||||
{
|
||||
// Generate a unique token that includes file position and hash
|
||||
return $"{task2.BytesRead}:{DateTime.UtcNow.Ticks}";
|
||||
}
|
||||
|
||||
private async Task FinalizeDownload(DownloadTask2 task2)
|
||||
{
|
||||
var tempPath = task2.CachePath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
// Decrypt the file if needed
|
||||
if (task2.Info.EncryptionAlgorithm != 0)
|
||||
{
|
||||
await DecryptFile(tempPath, task2.CachePath, task2.Info);
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tempPath, task2.CachePath);
|
||||
}
|
||||
|
||||
CompleteDownload(task2);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// _logger.Error($"Failed to finalize download for {task.Info.AssetId}: {ex.Message}");
|
||||
task2.Status = DownloadTaskStatus.Failed;
|
||||
File.Delete(tempPath);
|
||||
_activeDownloads.TryRemove(task2.Info.DownloadId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DecryptFile(string sourcePath, string targetPath, DownloadInfo info)
|
||||
{
|
||||
// Implementation of file decryption based on EncryptionAlgorithm
|
||||
// This would use the FileKey from the DownloadInfo
|
||||
throw new NotImplementedException("File decryption not implemented");
|
||||
}
|
||||
|
||||
private void HandleCancellation(DownloadTask2 task2, Stream dataStream, Stream fileStream)
|
||||
{
|
||||
if (task2.Status != DownloadTaskStatus.Failed)
|
||||
task2.Status = DownloadTaskStatus.Cancelled;
|
||||
|
||||
dataStream.Close();
|
||||
fileStream.Close();
|
||||
|
||||
task2.Progress = 0;
|
||||
task2.BytesRead = 0;
|
||||
|
||||
_activeDownloads.TryRemove(task2.Info.DownloadId, out _);
|
||||
}
|
||||
|
||||
private void UpdateProgress(DownloadTask2 task2, int bytesRead)
|
||||
{
|
||||
task2.BytesRead += bytesRead;
|
||||
task2.Progress = Math.Clamp(
|
||||
(int)(((float)task2.BytesRead / task2.Info.FileSize) * 100f),
|
||||
0, 100);
|
||||
}
|
||||
|
||||
private void CompleteDownload(DownloadTask2 task2)
|
||||
{
|
||||
task2.Status = DownloadTaskStatus.Complete;
|
||||
task2.Progress = 100;
|
||||
_activeDownloads.TryRemove(task2.Info.DownloadId, out _);
|
||||
_completedDownloads.TryAdd(task2.Info.DownloadId, task2);
|
||||
|
||||
lock (_downloadLock)
|
||||
{
|
||||
if (_queuedDownloads.TryDequeue(out var nextTask, out _))
|
||||
{
|
||||
StartDownload(nextTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
using ABI_RC.Core.IO;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public partial class DownloadManager2
|
||||
{
|
||||
public void QueueDownload(DownloadTask2 newTask2)
|
||||
{
|
||||
if (_completedDownloads.TryGetValue(newTask2.Info.DownloadId, out var completedTask))
|
||||
{
|
||||
completedTask.AddPlayer(newTask2.PlayerIds.FirstOrDefault());
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryFindTask(newTask2.Info.DownloadId, out var existingTask))
|
||||
{
|
||||
existingTask.AddPlayer(newTask2.PlayerIds.FirstOrDefault());
|
||||
RecalculatePriority(existingTask);
|
||||
return;
|
||||
}
|
||||
|
||||
float priority = CalculatePriority(newTask2);
|
||||
newTask2.CurrentPriority = priority;
|
||||
|
||||
lock (_downloadLock)
|
||||
{
|
||||
if (_activeDownloads.Count < MaxConcurrentDownloads)
|
||||
{
|
||||
StartDownload(newTask2);
|
||||
return;
|
||||
}
|
||||
|
||||
var lowestPriorityTask = _activeDownloads.Values
|
||||
.OrderByDescending(t => t.CurrentPriority * (1 + t.Progress / 100f))
|
||||
.LastOrDefault();
|
||||
|
||||
if (lowestPriorityTask != null && priority < lowestPriorityTask.CurrentPriority)
|
||||
{
|
||||
PauseDownload(lowestPriorityTask);
|
||||
StartDownload(newTask2);
|
||||
}
|
||||
else
|
||||
{
|
||||
_queuedDownloads.Enqueue(newTask2, priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StartDownload(DownloadTask2 task2)
|
||||
{
|
||||
task2.Status = DownloadTaskStatus.Downloading;
|
||||
_activeDownloads.TryAdd(task2.Info.DownloadId, task2);
|
||||
Task.Run(() => ProcessDownload(task2));
|
||||
}
|
||||
|
||||
private void PauseDownload(DownloadTask2 task2)
|
||||
{
|
||||
task2.Status = DownloadTaskStatus.Queued;
|
||||
_activeDownloads.TryRemove(task2.Info.DownloadId, out _);
|
||||
_queuedDownloads.Enqueue(task2, task2.CurrentPriority);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private void RecalculatePriority(DownloadTask2 task2)
|
||||
{
|
||||
var newPriority = CalculatePriority(task2);
|
||||
if (Math.Abs(newPriority - task2.CurrentPriority) < float.Epsilon)
|
||||
return;
|
||||
|
||||
task2.CurrentPriority = newPriority;
|
||||
|
||||
if (task2.Status == DownloadTaskStatus.Queued || task2.Status == DownloadTaskStatus.Paused)
|
||||
{
|
||||
// Re-enqueue with new priority
|
||||
_queuedDownloads.UpdatePriority(task2, newPriority);
|
||||
}
|
||||
}
|
||||
}
|
62
BetterContentLoading/DownloadManager2/DownloadTask2.cs
Normal file
62
BetterContentLoading/DownloadManager2/DownloadTask2.cs
Normal file
|
@ -0,0 +1,62 @@
|
|||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public class DownloadTask2
|
||||
{
|
||||
public DownloadInfo Info { get; }
|
||||
public DownloadTaskStatus Status { get; set; }
|
||||
public DownloadTaskType Type { get; }
|
||||
|
||||
public float BasePriority { get; set; }
|
||||
public float CurrentPriority => BasePriority * (1 + Progress / 100f);
|
||||
|
||||
public long BytesRead { get; set; }
|
||||
public int Progress { get; set; }
|
||||
|
||||
public string CachePath { get; }
|
||||
public Dictionary<string, string> Targets { get; } // Key: targetId (playerId/instanceId), Value: spawnerId
|
||||
public bool LoadOnComplete { get; } // For worlds only
|
||||
|
||||
public DownloadTask2(
|
||||
DownloadInfo info,
|
||||
string cachePath,
|
||||
DownloadTaskType type,
|
||||
bool loadOnComplete = false)
|
||||
{
|
||||
Info = info;
|
||||
CachePath = cachePath;
|
||||
Type = type;
|
||||
LoadOnComplete = loadOnComplete;
|
||||
Targets = new Dictionary<string, string>();
|
||||
Status = DownloadTaskStatus.Queued;
|
||||
}
|
||||
|
||||
public void AddTarget(string targetId, string spawnerId = null)
|
||||
{
|
||||
if (Type == DownloadTaskType.World && Targets.Count > 0)
|
||||
throw new InvalidOperationException("World downloads cannot have multiple targets");
|
||||
|
||||
Targets[targetId] = spawnerId;
|
||||
}
|
||||
|
||||
public void RemoveTarget(string targetId)
|
||||
{
|
||||
Targets.Remove(targetId);
|
||||
}
|
||||
}
|
||||
|
||||
public enum DownloadTaskType
|
||||
{
|
||||
Avatar,
|
||||
Prop,
|
||||
World
|
||||
}
|
||||
|
||||
public enum DownloadTaskStatus
|
||||
{
|
||||
Queued,
|
||||
Downloading,
|
||||
Paused,
|
||||
Complete,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
37
BetterContentLoading/Main.cs
Normal file
37
BetterContentLoading/Main.cs
Normal file
|
@ -0,0 +1,37 @@
|
|||
using MelonLoader;
|
||||
using NAK.BetterContentLoading.Patches;
|
||||
|
||||
namespace NAK.BetterContentLoading;
|
||||
|
||||
public class BetterContentLoadingMod : MelonMod
|
||||
{
|
||||
internal static MelonLogger.Instance Logger;
|
||||
|
||||
#region Melon Events
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
{
|
||||
Logger = LoggerInstance;
|
||||
|
||||
ApplyPatches(typeof(CVRDownloadManager_Patches));
|
||||
}
|
||||
|
||||
#endregion Melon Events
|
||||
|
||||
#region Melon Mod Utilities
|
||||
|
||||
private void ApplyPatches(Type type)
|
||||
{
|
||||
try
|
||||
{
|
||||
HarmonyInstance.PatchAll(type);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
LoggerInstance.Msg($"Failed while patching {type.Name}!");
|
||||
LoggerInstance.Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Melon Mod Utilities
|
||||
}
|
31
BetterContentLoading/ModSettings.cs
Normal file
31
BetterContentLoading/ModSettings.cs
Normal file
|
@ -0,0 +1,31 @@
|
|||
using MelonLoader;
|
||||
|
||||
namespace NAK.RCCVirtualSteeringWheel;
|
||||
|
||||
internal static class ModSettings
|
||||
{
|
||||
#region Constants
|
||||
|
||||
private const string ModName = nameof(RCCVirtualSteeringWheel);
|
||||
|
||||
#endregion Constants
|
||||
|
||||
#region Melon Preferences
|
||||
|
||||
private static readonly MelonPreferences_Category Category =
|
||||
MelonPreferences.CreateCategory(ModName);
|
||||
|
||||
internal static readonly MelonPreferences_Entry<bool> EntryOverrideSteeringRange =
|
||||
Category.CreateEntry("override_steering_range", false,
|
||||
"Override Steering Range", description: "Should the steering wheel use a custom steering range instead of the vehicle's default?");
|
||||
|
||||
internal static readonly MelonPreferences_Entry<float> EntryCustomSteeringRange =
|
||||
Category.CreateEntry("custom_steering_range", 60f,
|
||||
"Custom Steering Range", description: "The custom steering range in degrees when override is enabled (default: 60)");
|
||||
|
||||
internal static readonly MelonPreferences_Entry<bool> EntryInvertSteering =
|
||||
Category.CreateEntry("invert_steering", false,
|
||||
"Invert Steering", description: "Inverts the steering direction");
|
||||
|
||||
#endregion Melon Preferences
|
||||
}
|
71
BetterContentLoading/Patches.cs
Normal file
71
BetterContentLoading/Patches.cs
Normal file
|
@ -0,0 +1,71 @@
|
|||
using ABI_RC.Core.InteractionSystem;
|
||||
using ABI_RC.Core.IO;
|
||||
using ABI_RC.Core.Networking.API;
|
||||
using ABI_RC.Core.Networking.API.Responses;
|
||||
using HarmonyLib;
|
||||
using NAK.BetterContentLoading.Util;
|
||||
|
||||
namespace NAK.BetterContentLoading.Patches;
|
||||
|
||||
internal static class CVRDownloadManager_Patches
|
||||
{
|
||||
[HarmonyPrefix]
|
||||
[HarmonyPatch(typeof(CVRDownloadManager), nameof(CVRDownloadManager.QueueTask))]
|
||||
private static bool Prefix_CVRDownloadManager_QueueTask(
|
||||
string assetId,
|
||||
DownloadTask2.ObjectType type,
|
||||
string assetUrl,
|
||||
string fileId,
|
||||
long fileSize,
|
||||
string fileKey,
|
||||
string toAttach,
|
||||
string fileHash = null,
|
||||
UgcTagsData tagsData = null,
|
||||
CVRLoadingAvatarController loadingAvatarController = null,
|
||||
bool joinOnComplete = false,
|
||||
bool isHomeRequested = false,
|
||||
int compatibilityVersion = 0,
|
||||
int encryptionAlgorithm = 0,
|
||||
string spawnerId = null)
|
||||
{
|
||||
DownloadInfo info;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case DownloadTask2.ObjectType.Avatar:
|
||||
info = new DownloadInfo(
|
||||
assetId, assetUrl, fileId, fileSize, fileKey, fileHash,
|
||||
compatibilityVersion, encryptionAlgorithm, tagsData);
|
||||
BetterDownloadManager.Instance.QueueAvatarDownload(in info, toAttach, loadingAvatarController);
|
||||
return true;
|
||||
case DownloadTask2.ObjectType.Prop:
|
||||
info = new DownloadInfo(
|
||||
assetId, assetUrl, fileId, fileSize, fileKey, fileHash,
|
||||
compatibilityVersion, encryptionAlgorithm, tagsData);
|
||||
BetterDownloadManager.Instance.QueuePropDownload(in info, toAttach, spawnerId);
|
||||
return true;
|
||||
case DownloadTask2.ObjectType.World:
|
||||
_ = ThreadingHelper.RunOffMainThreadAsync(() =>
|
||||
{
|
||||
var response = ApiConnection.MakeRequest<UgcWithFile>(
|
||||
ApiConnection.ApiOperation.WorldMeta,
|
||||
new { worldID = assetId }
|
||||
);
|
||||
|
||||
if (response?.Result.Data == null)
|
||||
return;
|
||||
|
||||
info = new DownloadInfo(
|
||||
assetId, response.Result.Data.FileLocation, response.Result.Data.FileId,
|
||||
response.Result.Data.FileSize, response.Result.Data.FileKey, response.Result.Data.FileHash,
|
||||
(int)response.Result.Data.CompatibilityVersion, (int)response.Result.Data.EncryptionAlgorithm,
|
||||
null);
|
||||
|
||||
BetterDownloadManager.Instance.QueueWorldDownload(in info, joinOnComplete, isHomeRequested);
|
||||
}, CancellationToken.None);
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
33
BetterContentLoading/Properties/AssemblyInfo.cs
Normal file
33
BetterContentLoading/Properties/AssemblyInfo.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System.Reflection;
|
||||
using MelonLoader;
|
||||
using NAK.BetterContentLoading;
|
||||
using NAK.BetterContentLoading.Properties;
|
||||
|
||||
[assembly: AssemblyVersion(AssemblyInfoParams.Version)]
|
||||
[assembly: AssemblyFileVersion(AssemblyInfoParams.Version)]
|
||||
[assembly: AssemblyInformationalVersion(AssemblyInfoParams.Version)]
|
||||
[assembly: AssemblyTitle(nameof(NAK.BetterContentLoading))]
|
||||
[assembly: AssemblyCompany(AssemblyInfoParams.Author)]
|
||||
[assembly: AssemblyProduct(nameof(NAK.BetterContentLoading))]
|
||||
|
||||
[assembly: MelonInfo(
|
||||
typeof(BetterContentLoadingMod),
|
||||
nameof(NAK.BetterContentLoading),
|
||||
AssemblyInfoParams.Version,
|
||||
AssemblyInfoParams.Author,
|
||||
downloadLink: "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/BetterContentLoading"
|
||||
)]
|
||||
|
||||
[assembly: MelonGame("Alpha Blend Interactive", "ChilloutVR")]
|
||||
[assembly: MelonPlatform(MelonPlatformAttribute.CompatiblePlatforms.WINDOWS_X64)]
|
||||
[assembly: MelonPlatformDomain(MelonPlatformDomainAttribute.CompatibleDomains.MONO)]
|
||||
[assembly: MelonColor(255, 246, 25, 99)] // red-pink
|
||||
[assembly: MelonAuthorColor(255, 158, 21, 32)] // red
|
||||
[assembly: HarmonyDontPatchAll]
|
||||
|
||||
namespace NAK.BetterContentLoading.Properties;
|
||||
internal static class AssemblyInfoParams
|
||||
{
|
||||
public const string Version = "1.0.0";
|
||||
public const string Author = "NotAKidoS";
|
||||
}
|
14
BetterContentLoading/README.md
Normal file
14
BetterContentLoading/README.md
Normal file
|
@ -0,0 +1,14 @@
|
|||
# RCCVirtualSteeringWheel
|
||||
|
||||
Allows you to physically grab rigged RCC steering wheels in VR to provide steering input. No explicit setup required other than defining the Steering Wheel transform within the RCC component.
|
||||
|
||||
---
|
||||
|
||||
Here is the block of text where I tell you this mod is not affiliated with or endorsed by ABI.
|
||||
https://documentation.abinteractive.net/official/legal/tos/#7-modding-our-games
|
||||
|
||||
> This mod is an independent creation not affiliated with, supported by, or approved by Alpha Blend Interactive.
|
||||
|
||||
> Use of this mod is done so at the user's own risk and the creator cannot be held responsible for any issues arising from its use.
|
||||
|
||||
> To the best of my knowledge, I have adhered to the Modding Guidelines established by Alpha Blend Interactive.~~~~
|
23
BetterContentLoading/format.json
Normal file
23
BetterContentLoading/format.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"_id": -1,
|
||||
"name": "RCCVirtualSteeringWheel",
|
||||
"modversion": "1.0.1",
|
||||
"gameversion": "2024r177",
|
||||
"loaderversion": "0.6.1",
|
||||
"modtype": "Mod",
|
||||
"author": "NotAKidoS",
|
||||
"description": "Allows you to physically grab rigged RCC steering wheels in VR to provide steering input. No explicit setup required other than defining the Steering Wheel transform within the RCC component.\n",
|
||||
"searchtags": [
|
||||
"rcc",
|
||||
"steering",
|
||||
"vehicle",
|
||||
"car"
|
||||
],
|
||||
"requirements": [
|
||||
"None"
|
||||
],
|
||||
"downloadlink": "https://github.com/NotAKidoS/NAK_CVR_Mods/releases/download/r44/RCCVirtualSteeringWheel.dll",
|
||||
"sourcelink": "https://github.com/NotAKidoS/NAK_CVR_Mods/tree/main/RCCVirtualSteeringWheel/",
|
||||
"changelog": "- Initial release",
|
||||
"embedcolor": "#f61963"
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue