Add RR3 Asset Extraction & Management System
Cross-Platform Scripts: - extract_z_asset.sh: Linux/Unix single file extraction - batch_extract_z_assets.sh: Linux/Unix batch extraction - pack_z_asset.sh: Linux/Unix asset packing - extract_z_asset.ps1: Windows PowerShell extraction Server Integration: - AssetExtractionService.cs: C# service for ZLIB extraction/packing - AssetManagementController.cs: API endpoints for asset management - POST /api/AssetManagement/extract - POST /api/AssetManagement/pack - POST /api/AssetManagement/batch-extract - GET /api/AssetManagement/list - Registered AssetExtractionService in Program.cs Features: - Extracts .z files (ZLIB compressed textures/data) - Packs files to .z format with ZLIB compression - Batch processing support - Cross-platform (Windows/Linux/macOS) - Server-side API for remote asset management - Path traversal protection Documentation: - ASSET_EXTRACTION_GUIDE.md: Complete integration guide - Tools/README.md: CLI tool documentation Based on: Tankonline/Real-Racing-3-Texture-Extraction-Tool Converted to cross-platform bash/PowerShell scripts + C# service Ready for .pak asset extraction when files arrive from community Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
189
RR3CommunityServer/Controllers/AssetManagementController.cs
Normal file
189
RR3CommunityServer/Controllers/AssetManagementController.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RR3CommunityServer.Services;
|
||||
|
||||
namespace RR3CommunityServer.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AssetManagementController : ControllerBase
|
||||
{
|
||||
private readonly AssetExtractionService _assetExtraction;
|
||||
private readonly ILogger<AssetManagementController> _logger;
|
||||
private readonly string _assetBasePath;
|
||||
|
||||
public AssetManagementController(
|
||||
AssetExtractionService assetExtraction,
|
||||
ILogger<AssetManagementController> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_assetExtraction = assetExtraction;
|
||||
_logger = logger;
|
||||
_assetBasePath = configuration["AssetBasePath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "Assets");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a single .z asset file
|
||||
/// </summary>
|
||||
[HttpPost("extract")]
|
||||
public async Task<IActionResult> ExtractAsset([FromBody] ExtractRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(_assetBasePath, request.FileName);
|
||||
var outputPath = await _assetExtraction.ExtractZFileAsync(filePath, request.OutputPath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
resultCode = 0,
|
||||
message = "Success",
|
||||
data = new
|
||||
{
|
||||
inputFile = filePath,
|
||||
outputFile = outputPath,
|
||||
size = new FileInfo(outputPath).Length
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { resultCode = 404, message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error extracting asset");
|
||||
return StatusCode(500, new { resultCode = 500, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack a file to .z format
|
||||
/// </summary>
|
||||
[HttpPost("pack")]
|
||||
public async Task<IActionResult> PackAsset([FromBody] PackRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(_assetBasePath, request.FileName);
|
||||
var outputPath = await _assetExtraction.PackZFileAsync(filePath, request.OutputPath);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
resultCode = 0,
|
||||
message = "Success",
|
||||
data = new
|
||||
{
|
||||
inputFile = filePath,
|
||||
outputFile = outputPath,
|
||||
size = new FileInfo(outputPath).Length
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { resultCode = 404, message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error packing asset");
|
||||
return StatusCode(500, new { resultCode = 500, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch extract all .z files in a directory
|
||||
/// </summary>
|
||||
[HttpPost("batch-extract")]
|
||||
public async Task<IActionResult> BatchExtract([FromBody] BatchExtractRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inputDir = Path.Combine(_assetBasePath, request.InputDirectory);
|
||||
var outputDir = string.IsNullOrEmpty(request.OutputDirectory)
|
||||
? null
|
||||
: Path.Combine(_assetBasePath, request.OutputDirectory);
|
||||
|
||||
var results = await _assetExtraction.BatchExtractAsync(inputDir, outputDir);
|
||||
|
||||
var successful = results.Count(r => !r.Value.StartsWith("ERROR:"));
|
||||
var failed = results.Count - successful;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
resultCode = 0,
|
||||
message = "Success",
|
||||
data = new
|
||||
{
|
||||
totalFiles = results.Count,
|
||||
successful,
|
||||
failed,
|
||||
results = results.Select(r => new
|
||||
{
|
||||
inputFile = Path.GetFileName(r.Key),
|
||||
outputFile = Path.GetFileName(r.Value),
|
||||
status = r.Value.StartsWith("ERROR:") ? "failed" : "success",
|
||||
error = r.Value.StartsWith("ERROR:") ? r.Value : null
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { resultCode = 404, message = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in batch extraction");
|
||||
return StatusCode(500, new { resultCode = 500, message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List all available .z assets
|
||||
/// </summary>
|
||||
[HttpGet("list")]
|
||||
public IActionResult ListAssets([FromQuery] string? directory = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var searchDir = string.IsNullOrEmpty(directory)
|
||||
? _assetBasePath
|
||||
: Path.Combine(_assetBasePath, directory);
|
||||
|
||||
if (!Directory.Exists(searchDir))
|
||||
{
|
||||
return NotFound(new { resultCode = 404, message = "Directory not found" });
|
||||
}
|
||||
|
||||
var zFiles = Directory.GetFiles(searchDir, "*.z", SearchOption.AllDirectories)
|
||||
.Select(f => new
|
||||
{
|
||||
fileName = Path.GetFileName(f),
|
||||
relativePath = Path.GetRelativePath(_assetBasePath, f),
|
||||
size = new FileInfo(f).Length,
|
||||
modified = new FileInfo(f).LastWriteTimeUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
resultCode = 0,
|
||||
message = "Success",
|
||||
data = new
|
||||
{
|
||||
directory = searchDir,
|
||||
fileCount = zFiles.Count,
|
||||
files = zFiles
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing assets");
|
||||
return StatusCode(500, new { resultCode = 500, message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record ExtractRequest(string FileName, string? OutputPath = null);
|
||||
public record PackRequest(string FileName, string? OutputPath = null);
|
||||
public record BatchExtractRequest(string InputDirectory, string? OutputDirectory = null);
|
||||
@@ -20,6 +20,7 @@ builder.Services.AddScoped<ISessionService, SessionService>();
|
||||
builder.Services.AddScoped<IUserService, UserService>();
|
||||
builder.Services.AddScoped<ICatalogService, CatalogService>();
|
||||
builder.Services.AddScoped<IDrmService, DrmService>();
|
||||
builder.Services.AddScoped<AssetExtractionService>();
|
||||
|
||||
// CORS for cross-origin requests
|
||||
builder.Services.AddCors(options =>
|
||||
|
||||
198
RR3CommunityServer/Services/AssetExtractionService.cs
Normal file
198
RR3CommunityServer/Services/AssetExtractionService.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace RR3CommunityServer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for extracting and packing RR3 .z (ZLIB compressed) asset files
|
||||
/// </summary>
|
||||
public class AssetExtractionService
|
||||
{
|
||||
private readonly ILogger<AssetExtractionService> _logger;
|
||||
|
||||
public AssetExtractionService(ILogger<AssetExtractionService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a .z file (ZLIB compressed) to its original format
|
||||
/// </summary>
|
||||
public async Task<byte[]> ExtractZFileAsync(string filePath)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: {filePath}");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Extracting .z file: {filePath}");
|
||||
|
||||
var data = await File.ReadAllBytesAsync(filePath);
|
||||
var output = new List<byte>();
|
||||
int i = 0;
|
||||
int blocksFound = 0;
|
||||
|
||||
// Scan for ZLIB magic bytes (0x78 followed by 0x9C, 0xDA, or 0x01)
|
||||
while (i < data.Length - 2)
|
||||
{
|
||||
if (data[i] == 0x78 && (data[i + 1] == 0x9C || data[i + 1] == 0xDA || data[i + 1] == 0x01))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to decompress from this position
|
||||
using var compressed = new MemoryStream(data, i + 2, data.Length - i - 2);
|
||||
using var deflate = new DeflateStream(compressed, CompressionMode.Decompress);
|
||||
using var decompressed = new MemoryStream();
|
||||
|
||||
await deflate.CopyToAsync(decompressed);
|
||||
var block = decompressed.ToArray();
|
||||
|
||||
if (block.Length > 0)
|
||||
{
|
||||
output.AddRange(block);
|
||||
_logger.LogDebug($"Block {blocksFound} found at offset 0x{i:X}");
|
||||
blocksFound++;
|
||||
i += block.Length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not a valid ZLIB block, continue scanning
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (blocksFound == 0)
|
||||
{
|
||||
throw new InvalidDataException("No valid ZLIB blocks found in file");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Extracted {blocksFound} blocks, total size: {output.Count:N0} bytes");
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a .z file and saves the result
|
||||
/// </summary>
|
||||
public async Task<string> ExtractZFileAsync(string inputPath, string? outputPath = null)
|
||||
{
|
||||
var extracted = await ExtractZFileAsync(inputPath);
|
||||
|
||||
// Default output path: remove .z extension
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
outputPath = inputPath.EndsWith(".z", StringComparison.OrdinalIgnoreCase)
|
||||
? inputPath[..^2]
|
||||
: $"{inputPath}.extracted";
|
||||
}
|
||||
|
||||
// Backup existing file
|
||||
if (File.Exists(outputPath))
|
||||
{
|
||||
var backupPath = $"{outputPath}.bak";
|
||||
if (!File.Exists(backupPath))
|
||||
{
|
||||
File.Move(outputPath, backupPath);
|
||||
_logger.LogInformation($"Created backup: {backupPath}");
|
||||
}
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(outputPath, extracted);
|
||||
_logger.LogInformation($"Saved extracted file: {outputPath}");
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs a file with ZLIB compression to create .z format
|
||||
/// </summary>
|
||||
public async Task<byte[]> PackZFileAsync(byte[] data)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var deflate = new DeflateStream(output, CompressionLevel.Optimal))
|
||||
{
|
||||
// Write ZLIB header (0x78 0x9C for default compression)
|
||||
output.WriteByte(0x78);
|
||||
output.WriteByte(0x9C);
|
||||
|
||||
await deflate.WriteAsync(data, 0, data.Length);
|
||||
}
|
||||
|
||||
var compressed = output.ToArray();
|
||||
var ratio = (1.0 - (double)compressed.Length / data.Length) * 100;
|
||||
|
||||
_logger.LogInformation($"Packed {data.Length:N0} bytes → {compressed.Length:N0} bytes (compression: {ratio:F1}%)");
|
||||
|
||||
return compressed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs a file with ZLIB compression
|
||||
/// </summary>
|
||||
public async Task<string> PackZFileAsync(string inputPath, string? outputPath = null)
|
||||
{
|
||||
if (!File.Exists(inputPath))
|
||||
{
|
||||
throw new FileNotFoundException($"File not found: {inputPath}");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Packing file: {inputPath}");
|
||||
|
||||
var data = await File.ReadAllBytesAsync(inputPath);
|
||||
var packed = await PackZFileAsync(data);
|
||||
|
||||
// Default output: add .z extension
|
||||
outputPath ??= $"{inputPath}.z";
|
||||
|
||||
await File.WriteAllBytesAsync(outputPath, packed);
|
||||
_logger.LogInformation($"Saved packed file: {outputPath}");
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch extract multiple .z files from a directory
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, string>> BatchExtractAsync(string inputDirectory, string? outputDirectory = null)
|
||||
{
|
||||
if (!Directory.Exists(inputDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Directory not found: {inputDirectory}");
|
||||
}
|
||||
|
||||
outputDirectory ??= Path.Combine(inputDirectory, "extracted");
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
var zFiles = Directory.GetFiles(inputDirectory, "*.z", SearchOption.AllDirectories);
|
||||
_logger.LogInformation($"Found {zFiles.Length} .z files in {inputDirectory}");
|
||||
|
||||
var results = new Dictionary<string, string>();
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
|
||||
foreach (var zFile in zFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(zFile);
|
||||
var outputPath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
await ExtractZFileAsync(zFile, outputPath);
|
||||
results[zFile] = outputPath;
|
||||
success++;
|
||||
|
||||
_logger.LogInformation($"[{success + failed}/{zFiles.Length}] ✅ Extracted: {Path.GetFileName(zFile)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
_logger.LogError(ex, $"[{success + failed}/{zFiles.Length}] ❌ Failed: {Path.GetFileName(zFile)}");
|
||||
results[zFile] = $"ERROR: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Batch extraction complete: {success} success, {failed} failed");
|
||||
return results;
|
||||
}
|
||||
}
|
||||
BIN
RR3CommunityServer/bin/Release/net8.0/Humanizer.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/Humanizer.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.CodeAnalysis.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.CodeAnalysis.dll
Normal file
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.Data.Sqlite.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.Data.Sqlite.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.OpenApi.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/Microsoft.OpenApi.dll
Normal file
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/Mono.TextTemplating.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/Mono.TextTemplating.dll
Normal file
Binary file not shown.
1121
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.deps.json
Normal file
1121
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.deps.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.dll
Normal file
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.exe
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.exe
Normal file
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.pdb
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/RR3CommunityServer.pdb
Normal file
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"runtimeOptions": {
|
||||
"tfm": "net8.0",
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "Microsoft.NETCore.App",
|
||||
"version": "8.0.0"
|
||||
},
|
||||
{
|
||||
"name": "Microsoft.AspNetCore.App",
|
||||
"version": "8.0.0"
|
||||
}
|
||||
],
|
||||
"configProperties": {
|
||||
"System.GC.Server": true,
|
||||
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
|
||||
"System.Reflection.NullabilityInfoContext.IsSupported": true,
|
||||
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"Version":1,"ManifestType":"Build","Endpoints":[]}
|
||||
@@ -0,0 +1 @@
|
||||
{"ContentRoots":["E:\\rr3\\RR3CommunityServer\\RR3CommunityServer\\wwwroot\\"],"Root":{"Children":null,"Asset":null,"Patterns":[{"ContentRootIndex":0,"Pattern":"**","Depth":0}]}}
|
||||
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/SQLitePCLRaw.core.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/SQLitePCLRaw.core.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
RR3CommunityServer/bin/Release/net8.0/System.CodeDom.dll
Normal file
BIN
RR3CommunityServer/bin/Release/net8.0/System.CodeDom.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
RR3CommunityServer/bin/Release/net8.0/appsettings.json
Normal file
22
RR3CommunityServer/bin/Release/net8.0/appsettings.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AssetsBasePath": "Assets/downloaded",
|
||||
"CustomAssetsPath": "Assets/custom",
|
||||
"ModsPath": "Assets/mods",
|
||||
"ServerSettings": {
|
||||
"AllowSelfSignedCerts": true,
|
||||
"EnableAssetDownloads": true,
|
||||
"FreeGoldPurchases": true,
|
||||
"UnlockAllCars": false,
|
||||
"UnlimitedCurrency": false,
|
||||
"EnableModding": true,
|
||||
"MaxCustomCarUploadSizeMB": 100,
|
||||
"MaxCustomTrackUploadSizeMB": 200
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user