Move many mods to Deprecated folder, fix spelling

This commit is contained in:
NotAKidoS 2025-04-03 02:57:35 -05:00
parent 5e822cec8d
commit 0042590aa6
539 changed files with 7475 additions and 3120 deletions

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

View file

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

View 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}";
}

View 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
}
}
}
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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
}

View file

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

View file

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

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

View file

@ -0,0 +1,6 @@
namespace NAK.BetterContentLoading;
public partial class DownloadTask
{
}

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View 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
}

View 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
}

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

View 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";
}

View 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.~~~~

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