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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user