From 5d2c3bf8805e2cef3cc3fbfea454397430c5b3b4 Mon Sep 17 00:00:00 2001 From: Daniel Elliott Date: Thu, 19 Feb 2026 15:16:43 -0800 Subject: [PATCH] Add asset management system - Created Assets.cshtml and Assets.cshtml.cs for admin panel - Upload assets with MD5/SHA256 hash calculation - Generate asset manifests in RR3 format (tab-separated) - Integrated with Nimble SDK asset download system - Updated GameAsset model with IsRequired, UploadedAt, Description - Added navigation link in _Layout.cshtml - Supports categories: base, cars, tracks, audio, textures, UI, DLC - Asset download endpoint at /content/api/{assetPath} - Manifest endpoint at /content/api/manifest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- RR3CommunityServer/Data/RR3DbContext.cs | 7 +- RR3CommunityServer/Pages/Assets.cshtml | 321 ++++++++++++++++++++++ RR3CommunityServer/Pages/Assets.cshtml.cs | 289 +++++++++++++++++++ RR3CommunityServer/Pages/_Layout.cshtml | 10 + 4 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 RR3CommunityServer/Pages/Assets.cshtml create mode 100644 RR3CommunityServer/Pages/Assets.cshtml.cs diff --git a/RR3CommunityServer/Data/RR3DbContext.cs b/RR3CommunityServer/Data/RR3DbContext.cs index 8d6ac48..97b398b 100644 --- a/RR3CommunityServer/Data/RR3DbContext.cs +++ b/RR3CommunityServer/Data/RR3DbContext.cs @@ -381,16 +381,18 @@ public class GameAsset public string? EaCdnPath { get; set; } // Local storage - public string LocalPath { get; set; } = string.Empty; + public string? LocalPath { get; set; } public long FileSize { get; set; } - public string FileSha256 { get; set; } = string.Empty; + public string? FileSha256 { get; set; } public string? Version { get; set; } // Metadata public DateTime DownloadedAt { get; set; } = DateTime.UtcNow; + public DateTime UploadedAt { get; set; } = DateTime.UtcNow; public DateTime LastAccessedAt { get; set; } = DateTime.UtcNow; public int AccessCount { get; set; } = 0; public bool IsAvailable { get; set; } = true; + public bool IsRequired { get; set; } = false; // Game-specific (optional) public string? CarId { get; set; } @@ -398,6 +400,7 @@ public class GameAsset public string Category { get; set; } = "misc"; // models, textures, audio, etc. public long? CompressedSize { get; set; } public string? Md5Hash { get; set; } + public string? Description { get; set; } // Custom content support public bool IsCustomContent { get; set; } diff --git a/RR3CommunityServer/Pages/Assets.cshtml b/RR3CommunityServer/Pages/Assets.cshtml new file mode 100644 index 0000000..27b35d1 --- /dev/null +++ b/RR3CommunityServer/Pages/Assets.cshtml @@ -0,0 +1,321 @@ +@page +@model RR3CommunityServer.Pages.AssetsModel +@{ + ViewData["Title"] = "Asset Management"; +} + +
+
+
+
+
+

đŸ“Ļ Asset Management

+

Upload and manage game assets for client downloads

+
+ ← Back to Dashboard +
+
+
+ + @if (!string.IsNullOrEmpty(Model.Message)) + { + + } + + +
+
+
+
+

@Model.Stats.TotalAssets

+

Total Assets

+
+
+
+
+
+
+

@Model.Stats.AvailableAssets

+

Available

+
+
+
+
+
+
+

@Model.Stats.TotalSizeMB MB

+

Total Size

+
+
+
+
+
+
+

@Model.Stats.TotalDownloads

+

Downloads

+
+
+
+
+ + +
+
+
+
+
âŦ†ī¸ Upload New Asset
+
+
+
+
+
+
+ + + Supported: .pak, .z, .dat, .nct, .json, .xml, images, audio +
+
+
+
+ + + Path format: /rr3/category/filename.ext +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+
+
+
+ + +
+
+
+
+
📋 Asset Inventory
+
+ +
+ +
+
+
+
+ @if (!Model.Assets.Any()) + { +
+ No assets uploaded yet. Use the form above to upload your first asset. +
+ } + else + { +
+ + + + + + + + + + + + + + + + @foreach (var asset in Model.Assets) + { + + + + + + + + + + + + } + +
File NameEA CDN PathCategoryTypeSizeMD5DownloadsRequiredActions
+ @asset.FileName + @if (!string.IsNullOrEmpty(asset.Description)) + { +
@asset.Description + } +
@asset.EaCdnPath@asset.Category@asset.AssetType@FormatFileSize(asset.FileSize) + @(asset.Md5Hash?.Substring(0, 8) ?? "N/A")... + @if (!string.IsNullOrEmpty(asset.Md5Hash)) + { + + } + @asset.AccessCount + @if (asset.IsRequired) + { + Required + } + else + { + Optional + } + +
+ + + +
+ +
+
+
+
+ } +
+
+
+
+ + +
+
+
+
+
â„šī¸ Nimble SDK Asset Download System
+
+
+
How RR3 Downloads Assets:
+
    +
  1. Game Startup: UnpackAssetsActivity extracts bundled APK assets
  2. +
  3. Manifest Request: Game calls GET /content/api/manifest
  4. +
  5. Verification: Compares local assets with manifest (MD5 checksums)
  6. +
  7. Download Missing: Calls GET /content/api/[asset-path] for missing files
  8. +
  9. Storage: Saves to /external/storage/apk/ directory
  10. +
  11. Launch Game: All required assets present, game starts
  12. +
+ +
Asset Manifest Format:
+
{
+  "resultCode": 0,
+  "message": "Success",
+  "data": [
+    {
+      "path": "/rr3/base/game_data.pak",
+      "md5": "a1b2c3d4e5f6...",
+      "compressedSize": 1048576,
+      "uncompressedSize": 2097152,
+      "category": "base"
+    }
+  ]
+}
+ +
Nimble SDK Authentication Headers:
+
    +
  • EAM-SESSION - Session UUID
  • +
  • EAM-USER-ID - User identifier
  • +
  • EA-SELL-ID - Marketplace (e.g., GOOGLE_PLAY)
  • +
  • SDK-VERSION - Nimble SDK version
  • +
+ +
+ Important: Assets must have correct MD5 hashes or the game will reject them and re-download. +
+
+
+
+
+
+ +@section Scripts { + +} + +@functions { + private string FormatFileSize(long bytes) + { + string[] sizes = { "B", "KB", "MB", "GB" }; + double len = bytes; + int order = 0; + while (len >= 1024 && order < sizes.Length - 1) + { + order++; + len = len / 1024; + } + return $"{len:0.##} {sizes[order]}"; + } +} diff --git a/RR3CommunityServer/Pages/Assets.cshtml.cs b/RR3CommunityServer/Pages/Assets.cshtml.cs new file mode 100644 index 0000000..764bb7d --- /dev/null +++ b/RR3CommunityServer/Pages/Assets.cshtml.cs @@ -0,0 +1,289 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using RR3CommunityServer.Data; +using RR3CommunityServer.Models; +using System.Security.Cryptography; + +namespace RR3CommunityServer.Pages; + +[Authorize] +public class AssetsModel : PageModel +{ + private readonly RR3DbContext _context; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly string _assetsBasePath; + + public AssetsModel(RR3DbContext context, IConfiguration configuration, ILogger logger) + { + _context = context; + _configuration = configuration; + _logger = logger; + _assetsBasePath = configuration.GetValue("AssetsBasePath") + ?? Path.Combine(Directory.GetCurrentDirectory(), "Assets", "downloaded"); + } + + public List Assets { get; set; } = new(); + public AssetStats Stats { get; set; } = new(); + public string? Message { get; set; } + public bool IsError { get; set; } + + public async Task OnGetAsync() + { + Assets = await _context.GameAssets + .OrderByDescending(a => a.UploadedAt) + .ToListAsync(); + + await CalculateStatsAsync(); + } + + public async Task OnPostUploadAsync( + IFormFile assetFile, + string eaCdnPath, + string category, + string assetType, + bool isRequired, + string? description) + { + try + { + if (assetFile == null || assetFile.Length == 0) + { + Message = "No file selected."; + IsError = true; + await OnGetAsync(); + return Page(); + } + + // Ensure assets directory exists + if (!Directory.Exists(_assetsBasePath)) + { + Directory.CreateDirectory(_assetsBasePath); + } + + // Create category subdirectory + var categoryPath = Path.Combine(_assetsBasePath, category); + if (!Directory.Exists(categoryPath)) + { + Directory.CreateDirectory(categoryPath); + } + + // Save file to disk + var fileName = Path.GetFileName(assetFile.FileName); + var localPath = Path.Combine(categoryPath, fileName); + + using (var stream = new FileStream(localPath, FileMode.Create)) + { + await assetFile.CopyToAsync(stream); + } + + // Calculate MD5 and SHA256 + var md5Hash = await CalculateMd5Async(localPath); + var sha256Hash = await CalculateSha256Async(localPath); + var fileInfo = new FileInfo(localPath); + + // Normalize EA CDN path + if (!eaCdnPath.StartsWith("/")) + { + eaCdnPath = "/" + eaCdnPath; + } + + // Check if asset already exists + var existingAsset = await _context.GameAssets + .FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath); + + if (existingAsset != null) + { + // Update existing asset + existingAsset.FileName = fileName; + existingAsset.LocalPath = localPath; + existingAsset.FileSize = fileInfo.Length; + existingAsset.Md5Hash = md5Hash; + existingAsset.FileSha256 = sha256Hash; + existingAsset.Category = category; + existingAsset.AssetType = assetType; + existingAsset.IsRequired = isRequired; + existingAsset.Description = description; + existingAsset.ContentType = GetContentType(fileName); + existingAsset.UploadedAt = DateTime.UtcNow; + + Message = $"Asset '{fileName}' updated successfully!"; + } + else + { + // Create new asset + var asset = new GameAsset + { + FileName = fileName, + EaCdnPath = eaCdnPath, + LocalPath = localPath, + FileSize = fileInfo.Length, + Md5Hash = md5Hash, + FileSha256 = sha256Hash, + Category = category, + AssetType = assetType, + IsRequired = isRequired, + Description = description, + ContentType = GetContentType(fileName), + UploadedAt = DateTime.UtcNow, + DownloadedAt = DateTime.UtcNow + }; + + _context.GameAssets.Add(asset); + Message = $"Asset '{fileName}' uploaded successfully!"; + } + + await _context.SaveChangesAsync(); + + _logger.LogInformation("Asset uploaded: {FileName} -> {CdnPath}", fileName, eaCdnPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading asset"); + Message = $"Error uploading asset: {ex.Message}"; + IsError = true; + } + + await OnGetAsync(); + return Page(); + } + + public async Task OnPostDeleteAsync(int id) + { + try + { + var asset = await _context.GameAssets.FindAsync(id); + if (asset == null) + { + Message = "Asset not found."; + IsError = true; + await OnGetAsync(); + return Page(); + } + + // Delete file from disk + if (!string.IsNullOrEmpty(asset.LocalPath) && System.IO.File.Exists(asset.LocalPath)) + { + System.IO.File.Delete(asset.LocalPath); + } + + _context.GameAssets.Remove(asset); + await _context.SaveChangesAsync(); + + Message = $"Asset '{asset.FileName}' deleted successfully!"; + _logger.LogInformation("Asset deleted: {FileName}", asset.FileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting asset"); + Message = $"Error deleting asset: {ex.Message}"; + IsError = true; + } + + await OnGetAsync(); + return Page(); + } + + public async Task OnPostGenerateManifestAsync() + { + try + { + var assets = await _context.GameAssets.ToListAsync(); + + // Generate manifest in RR3 format (tab-separated) + var manifestContent = new System.Text.StringBuilder(); + foreach (var asset in assets) + { + // Format: /path/to/file.ext md5hash compressedSize uncompressedSize + manifestContent.AppendLine($"{asset.EaCdnPath}\t{asset.Md5Hash}\t{asset.CompressedSize ?? asset.FileSize}\t{asset.FileSize}"); + } + + // Save to Assets directory + var manifestPath = Path.Combine(_assetsBasePath, "asset_list_community.txt"); + await System.IO.File.WriteAllTextAsync(manifestPath, manifestContent.ToString()); + + // Also generate JSON manifest for API + var jsonManifest = assets.Select(a => new + { + path = a.EaCdnPath, + md5 = a.Md5Hash, + compressedSize = a.CompressedSize ?? a.FileSize, + uncompressedSize = a.FileSize, + category = a.Category, + required = a.IsRequired + }); + + var jsonPath = Path.Combine(_assetsBasePath, "asset_manifest_community.json"); + await System.IO.File.WriteAllTextAsync(jsonPath, + System.Text.Json.JsonSerializer.Serialize(jsonManifest, new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + + Message = $"Manifest generated successfully! ({assets.Count} assets)"; + _logger.LogInformation("Asset manifest generated with {Count} assets", assets.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating manifest"); + Message = $"Error generating manifest: {ex.Message}"; + IsError = true; + } + + await OnGetAsync(); + return Page(); + } + + private async Task CalculateStatsAsync() + { + Stats.TotalAssets = Assets.Count; + Stats.AvailableAssets = Assets.Count(a => !string.IsNullOrEmpty(a.LocalPath) && System.IO.File.Exists(a.LocalPath)); + Stats.TotalSizeMB = (long)(Assets.Sum(a => a.FileSize) / 1024.0 / 1024.0); + Stats.TotalDownloads = Assets.Sum(a => a.AccessCount); + } + + private async Task CalculateMd5Async(string filePath) + { + using var md5 = MD5.Create(); + using var stream = System.IO.File.OpenRead(filePath); + var hash = await md5.ComputeHashAsync(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private async Task CalculateSha256Async(string filePath) + { + using var sha256 = SHA256.Create(); + using var stream = System.IO.File.OpenRead(filePath); + var hash = await sha256.ComputeHashAsync(stream); + return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); + } + + private string GetContentType(string fileName) + { + var extension = Path.GetExtension(fileName).ToLowerInvariant(); + return extension switch + { + ".pak" => "application/octet-stream", + ".dat" => "application/octet-stream", + ".nct" => "application/octet-stream", + ".z" => "application/x-compress", + ".json" => "application/json", + ".xml" => "application/xml", + ".png" => "image/png", + ".jpg" or ".jpeg" => "image/jpeg", + ".pvr" => "image/pvr", + ".atlas" => "application/octet-stream", + ".mp3" => "audio/mpeg", + ".ogg" => "audio/ogg", + ".wav" => "audio/wav", + _ => "application/octet-stream" + }; + } +} + +public class AssetStats +{ + public int TotalAssets { get; set; } + public int AvailableAssets { get; set; } + public long TotalSizeMB { get; set; } + public int TotalDownloads { get; set; } +} diff --git a/RR3CommunityServer/Pages/_Layout.cshtml b/RR3CommunityServer/Pages/_Layout.cshtml index a2a4e6d..1243c87 100644 --- a/RR3CommunityServer/Pages/_Layout.cshtml +++ b/RR3CommunityServer/Pages/_Layout.cshtml @@ -103,6 +103,16 @@ Purchases + +