├── appsettings.Development.json ├── appsettings.json ├── app.manifest ├── .gitignore ├── Properties └── launchSettings.json ├── Config └── userconfig.json ├── Models └── ApiConfig.cs ├── Utils ├── DirUtils.cs ├── PathUtils.cs └── TextureExtractor.cs ├── LICENSE ├── CodeWalker.API.sln ├── Controllers ├── HashController.cs ├── SearchController.cs ├── ReplaceController.cs ├── ConfigController.cs ├── DownloadController.cs └── ImportController.cs ├── Services ├── ServiceFactory.cs ├── ServiceManager.cs ├── ConfigService.cs ├── RpfService.cs └── ReloadableServiceContainer.cs ├── CodeWalker.API.csproj ├── .github └── workflows │ └── release.yml ├── README.md ├── Program.cs └── CodeWalker.API.http /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Warning", 6 | "CodeWalker.API": "Debug" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Warning", 6 | "CodeWalker.API": "Debug" 7 | } 8 | }, 9 | "AllowedHosts": "localhost;127.0.0.1" 10 | } 11 | -------------------------------------------------------------------------------- /app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | bin/ 3 | obj/ 4 | 5 | # User-specific files 6 | .vs/ 7 | *.user 8 | *.suo 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # Rider-specific files 13 | .idea/ 14 | *.sln.iml 15 | 16 | # Auto-generated packages 17 | *.nupkg 18 | packages/ 19 | NuGet.Config 20 | NuGet.exe 21 | project.lock.json 22 | project.assets.json 23 | 24 | # Visual Studio Code 25 | .vscode/ 26 | 27 | # Logs 28 | *.log 29 | -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5024", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Config/userconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "CodewalkerOutputDir": "C:\\GTA_FILES\\cw_out", 3 | "BlenderOutputDir": "C:\\GTA_FILES\\blender_out", 4 | "FivemOutputDir": "C:\\GTA_FILES\\fivem_out", 5 | "RpfArchivePath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V\\modstore\\new.rpf", 6 | "GTAPath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V", 7 | "Gen9": false, 8 | "DLC": "tfd", 9 | "EnableMods": true, 10 | "Port": 0 11 | } -------------------------------------------------------------------------------- /Models/ApiConfig.cs: -------------------------------------------------------------------------------- 1 | namespace CodeWalker.API.Models 2 | { 3 | public class ApiConfig 4 | { 5 | public string CodewalkerOutputDir { get; set; } = ""; 6 | public string BlenderOutputDir { get; set; } = ""; 7 | public string FivemOutputDir { get; set; } = ""; 8 | public string RpfArchivePath { get; set; } = ""; 9 | public string GTAPath { get; set; } = ""; 10 | public bool Gen9 { get; set; } = false; 11 | public string Dlc { get; set; } = ""; 12 | public bool EnableMods { get; set; } = false; 13 | public int Port { get; set; } = 0; 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /Utils/DirUtils.cs: -------------------------------------------------------------------------------- 1 | namespace CodeWalker.API.Utils 2 | { 3 | public static class DirUtils 4 | { 5 | public static void CopyDirectory(string sourceDir, string targetDir) 6 | { 7 | Directory.CreateDirectory(targetDir); 8 | foreach (var file in Directory.GetFiles(sourceDir)) 9 | System.IO.File.Copy(file, Path.Combine(targetDir, Path.GetFileName(file)), true); 10 | 11 | foreach (var dir in Directory.GetDirectories(sourceDir)) 12 | CopyDirectory(dir, Path.Combine(targetDir, Path.GetFileName(dir))); 13 | } 14 | 15 | } 16 | 17 | 18 | } 19 | -------------------------------------------------------------------------------- /Utils/PathUtils.cs: -------------------------------------------------------------------------------- 1 | namespace CodeWalker.API.Utils 2 | { 3 | public static class PathUtils 4 | { 5 | public static string NormalizePath(string path, bool useBackslashes = false) 6 | { 7 | var parts = path 8 | .Replace('\\', '/') 9 | .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); 10 | 11 | var joined = string.Join("/", parts); 12 | return useBackslashes ? joined.Replace('/', '\\') : joined; 13 | } 14 | 15 | public static bool PathEquals(string a, string b) 16 | { 17 | return NormalizePath(a).Equals(NormalizePath(b), StringComparison.OrdinalIgnoreCase); 18 | } 19 | 20 | } 21 | 22 | 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 tr1cks 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CodeWalker.API.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.13.35825.156 d17.13 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeWalker.API", "CodeWalker.API.csproj", "{158CA20B-F031-1235-E9A6-E20F9744F0CE}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {158CA20B-F031-1235-E9A6-E20F9744F0CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {158CA20B-F031-1235-E9A6-E20F9744F0CE}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {158CA20B-F031-1235-E9A6-E20F9744F0CE}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {158CA20B-F031-1235-E9A6-E20F9744F0CE}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {7C348D83-7338-4D82-A39E-078D061E1C78} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /Controllers/HashController.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.GameFiles; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Swashbuckle.AspNetCore.Annotations; 4 | 5 | namespace CodeWalker.API.Controllers 6 | { 7 | [ApiController] 8 | [Route("api/hash")] 9 | public class HashController : ControllerBase 10 | { 11 | [HttpGet("jenkins")] 12 | [SwaggerOperation( 13 | Summary = "Generate Jenkins hash", 14 | Description = "Generates a Jenkins hash from the input string and returns its uint, int, and hex representations." 15 | )] 16 | [SwaggerResponse(200, "Jenkins hash generated", typeof(JenkHash))] 17 | [SwaggerResponse(400, "Missing or invalid input")] 18 | public IActionResult GetJenkinsHash( 19 | [FromQuery, SwaggerParameter("The string to hash", Required = true)] string input, 20 | [FromQuery, SwaggerParameter("Encoding to use: UTF8 (0) or ASCII (1)")] JenkHashInputEncoding encoding = JenkHashInputEncoding.UTF8 21 | ) 22 | { 23 | if (string.IsNullOrWhiteSpace(input)) 24 | return BadRequest("Missing input string."); 25 | 26 | var result = new JenkHash(input, encoding); 27 | return Ok(new 28 | { 29 | input = result.Text, 30 | encoding = result.Encoding.ToString(), 31 | hashUint = result.HashUint, 32 | hashInt = result.HashInt, 33 | hashHex = result.HashHex 34 | }); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Controllers/SearchController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Swashbuckle.AspNetCore.Annotations; 3 | using System.Collections.Generic; 4 | 5 | [ApiController] 6 | [Route("api")] 7 | public class SearchController : ControllerBase 8 | { 9 | private readonly RpfService _rpfService; 10 | 11 | public SearchController(RpfService rpfService) 12 | { 13 | _rpfService = rpfService; 14 | } 15 | 16 | [HttpGet("search-file")] 17 | [SwaggerOperation( 18 | Summary = "Searches for files in the RPF archives", 19 | Description = "Searches for a file by name in the RPF archive and returns the matching results." 20 | )] 21 | [SwaggerResponse(200, "Successful search, returns matching file results", typeof(List))] 22 | [SwaggerResponse(400, "Bad request due to missing filename")] 23 | [SwaggerResponse(404, "File not found")] 24 | [SwaggerResponse(503, "Service unavailable - GTA path not configured")] 25 | public IActionResult SearchFile( 26 | [FromQuery, SwaggerParameter("Filename to search in RPF archive, e.g., filename=prop_alien_egg_01.ydr", Required = true)] string filename) 27 | 28 | { 29 | if (string.IsNullOrWhiteSpace(filename)) 30 | { 31 | return BadRequest("Filename is required."); 32 | } 33 | 34 | try 35 | { 36 | var results = _rpfService.SearchFile(filename); 37 | return results.Count > 0 ? Ok(results) : NotFound($"File '{filename}' not found."); 38 | } 39 | catch (InvalidOperationException ex) 40 | { 41 | return StatusCode(503, new { 42 | error = "Service unavailable", 43 | message = ex.Message, 44 | solution = "Use /api/set-config to configure a valid GTA path" 45 | }); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Services/ServiceFactory.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.GameFiles; 2 | using Microsoft.Extensions.Logging; 3 | 4 | namespace CodeWalker.API.Services 5 | { 6 | public class ServiceFactory 7 | { 8 | private readonly ILogger _logger; 9 | private readonly ConfigService _configService; 10 | 11 | public ServiceFactory(ILogger logger, ConfigService configService) 12 | { 13 | _logger = logger; 14 | _configService = configService; 15 | } 16 | 17 | public RpfService CreateRpfService(ILogger logger) 18 | { 19 | return new RpfService(logger, _configService); 20 | } 21 | 22 | public GameFileCache CreateGameFileCache() 23 | { 24 | var config = _configService.Get(); 25 | string gtaPath = config.GTAPath; 26 | 27 | long cacheSize = 4L * 1024 * 1024 * 1024; // 4GB Cache size considering gta v enhanced 28 | double cacheTime = 60.0; 29 | bool isGen9 = config.Gen9; 30 | string dlc = config.Dlc; 31 | bool enableMods = config.EnableMods; 32 | string excludeFolders = ""; 33 | 34 | var gameFileCache = new GameFileCache(cacheSize, cacheTime, gtaPath, isGen9, dlc, enableMods, excludeFolders); 35 | gameFileCache.EnableDlc = true; // this ensures Init() runs InitDlc() 36 | gameFileCache.Init( 37 | message => Console.WriteLine($"[GameFileCache] {message}"), 38 | error => Console.Error.WriteLine($"[GameFileCache ERROR] {error}") 39 | ); 40 | 41 | _logger.LogInformation("[ServiceFactory] Created GameFileCache with GTA Path: {GtaPath}, Archetypes: {Count}", 42 | gtaPath, gameFileCache.YtypDict?.Count ?? 0); 43 | 44 | return gameFileCache; 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /CodeWalker.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | enable 5 | enable 6 | app.manifest 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | PreserveNewest 32 | Always 33 | 34 | 35 | strings.txt 36 | PreserveNewest 37 | Always 38 | 39 | 40 | ShadersGen9Conversion.xml 41 | PreserveNewest 42 | Always 43 | 44 | 45 | Resources\magic.dat 46 | PreserveNewest 47 | Always 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Services/ServiceManager.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.GameFiles; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | 6 | namespace CodeWalker.API.Services 7 | { 8 | public class ServiceManager 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | private readonly ILogger _logger; 12 | private readonly ConfigService _configService; 13 | 14 | public ServiceManager(IServiceProvider serviceProvider, ILogger logger, ConfigService configService) 15 | { 16 | _serviceProvider = serviceProvider; 17 | _logger = logger; 18 | _configService = configService; 19 | 20 | // Subscribe to GTA path changes 21 | _configService.GtaPathChanged += OnGtaPathChanged; 22 | } 23 | 24 | private void OnGtaPathChanged(string newGtaPath) 25 | { 26 | _logger.LogInformation("[ServiceManager] GTA path changed to: {NewPath}", newGtaPath); 27 | ReloadServices(); 28 | } 29 | 30 | public void ReloadServices() 31 | { 32 | try 33 | { 34 | _logger.LogInformation("[ServiceManager] Starting service reload..."); 35 | 36 | // Reload RPF decryption keys 37 | var config = _configService.Get(); 38 | string gtaPath = config.GTAPath; 39 | 40 | if (!Directory.Exists(gtaPath)) 41 | { 42 | _logger.LogError("[ServiceManager] GTA V directory not found at {GtaPath}", gtaPath); 43 | return; 44 | } 45 | 46 | // Load RPF decryption keys 47 | try 48 | { 49 | _logger.LogInformation("[ServiceManager] Loading RPF decryption keys..."); 50 | GTA5Keys.LoadFromPath(gtaPath); 51 | _logger.LogInformation("[ServiceManager] RPF decryption keys loaded successfully."); 52 | } 53 | catch (Exception ex) 54 | { 55 | _logger.LogError(ex, "[ServiceManager] Failed to load RPF keys: {Message}", ex.Message); 56 | return; 57 | } 58 | 59 | // Note: For a complete reload, we would need to recreate the services 60 | // However, since they're registered as singletons, we need to use a different approach 61 | // This is a limitation of the current DI container setup 62 | _logger.LogInformation("[ServiceManager] Service reload completed. Note: Services will be recreated on next request."); 63 | } 64 | catch (Exception ex) 65 | { 66 | _logger.LogError(ex, "[ServiceManager] Error during service reload: {Message}", ex.Message); 67 | } 68 | } 69 | 70 | public void Dispose() 71 | { 72 | _configService.GtaPathChanged -= OnGtaPathChanged; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release CodeWalker API 2 | on: 3 | push: 4 | tags: 5 | - "v*" # Runs when a version tag like v1.0.0 is pushed 6 | 7 | jobs: 8 | build: 9 | runs-on: windows-latest # Windows required for CodeWalker 10 | 11 | steps: 12 | - name: Checkout CodeWalker.API Repo 13 | uses: actions/checkout@v4 14 | with: 15 | path: CodeWalker.API # Clones into ${{ github.workspace }}/CodeWalker.API 16 | 17 | - name: Clone Full CodeWalker Repo (for CodeWalker.Core) 18 | run: | 19 | git clone --depth=1 https://github.com/dexyfex/CodeWalker.git "${{ github.workspace }}/CodeWalkerRepo" 20 | 21 | - name: Move CodeWalker.Core to Workdirectory 22 | run: | 23 | move "${{ github.workspace }}\CodeWalkerRepo\CodeWalker.Core" "${{ github.workspace }}" 24 | 25 | - name: Verify CodeWalker.Core Exists 26 | shell: pwsh 27 | run: | 28 | if (!(Test-Path "${{ github.workspace }}/CodeWalker.Core/CodeWalker.Core.csproj")) { 29 | Write-Host "❌ CodeWalker.Core is missing!" 30 | exit 1 31 | } else { 32 | Write-Host "✅ CodeWalker.Core is present." 33 | } 34 | 35 | - name: 🔧 Patch CodeWalker.Core to fix Assembly.GetExecutingAssembly().Location 36 | shell: pwsh 37 | run: | 38 | $file = "${{ github.workspace }}/CodeWalker.Core/GameFiles/RpfManager.cs" 39 | (Get-Content $file) -replace 'var path = System\.Reflection\.Assembly\.GetExecutingAssembly\(\)\.Location;', 'var path = AppContext.BaseDirectory;' | Set-Content $file 40 | Write-Host "✅ Patched RpfManager.cs to use AppContext.BaseDirectory" 41 | 42 | - name: Setup MSBuild 43 | uses: microsoft/setup-msbuild@v1 44 | 45 | - name: Restore NuGet Packages 46 | run: | 47 | nuget restore "${{ github.workspace }}/CodeWalker.Core/CodeWalker.Core.csproj" 48 | nuget restore "${{ github.workspace }}/CodeWalker.API/CodeWalker.API.csproj" 49 | 50 | - name: Publish CodeWalker.API (as admin-elevated .exe) 51 | run: | 52 | cd "${{ github.workspace }}/CodeWalker.API" 53 | dotnet publish -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true 54 | 55 | - name: Package published output as ZIP 56 | shell: cmd 57 | run: | 58 | @echo on 59 | set PUBLISH_DIR=${{ github.workspace }}\CodeWalker.API\bin\Release\net9.0\win-x64\publish 60 | set OUTPUT_ZIP=${{ github.workspace }}\CodeWalker.API.zip 61 | echo "Zipping full publish directory..." 62 | powershell Compress-Archive -Path "%PUBLISH_DIR%\*" -DestinationPath "%OUTPUT_ZIP%" 63 | if not exist "%OUTPUT_ZIP%" ( 64 | echo "❌ ERROR: ZIP file was not created!" 65 | exit 1 66 | ) else ( 67 | echo "✅ ZIP file created successfully." 68 | ) 69 | 70 | - name: Debug - List Workspace Contents 71 | shell: cmd 72 | run: | 73 | @echo on 74 | dir /s "${{ github.workspace }}" 75 | 76 | - name: Upload Release 77 | uses: softprops/action-gh-release@v2 78 | with: 79 | files: "${{ github.workspace }}/CodeWalker.API.zip" 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} 82 | -------------------------------------------------------------------------------- /Controllers/ReplaceController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Swashbuckle.AspNetCore.Annotations; 3 | using Microsoft.Extensions.Logging; 4 | using System.IO; 5 | using CodeWalker.GameFiles; 6 | using CodeWalker.API.Services; 7 | using CodeWalker.API.Utils; 8 | using System; 9 | using System.Linq; 10 | 11 | [ApiController] 12 | [Route("api")] 13 | public class ReplaceController : ControllerBase 14 | { 15 | private readonly RpfService _rpfService; 16 | private readonly ConfigService _configService; 17 | private readonly ILogger _logger; 18 | 19 | public ReplaceController(RpfService rpfService, ConfigService configService, ILogger logger) 20 | { 21 | _rpfService = rpfService; 22 | _configService = configService; 23 | _logger = logger; 24 | } 25 | 26 | public class ReplaceFileRequest 27 | { 28 | public string? RpfFilePath { get; set; } 29 | public string? LocalFilePath { get; set; } 30 | } 31 | 32 | [HttpPost("replace-file")] 33 | [Consumes("application/json")] 34 | [SwaggerOperation(Summary = "Replaces a file in an RPF (JSON)", Description = "Replaces the file inside an RPF using a JSON body.")] 35 | [SwaggerResponse(200, "File replaced successfully")] 36 | [SwaggerResponse(400, "Bad request")] 37 | [SwaggerResponse(503, "Service unavailable - GTA path not configured")] 38 | public IActionResult ReplaceFileJson([FromBody] ReplaceFileRequest jsonBody) 39 | { 40 | return HandleReplacement(jsonBody?.RpfFilePath, jsonBody?.LocalFilePath); 41 | } 42 | 43 | [HttpPost("replace-file")] 44 | [Consumes("application/x-www-form-urlencoded")] 45 | [ApiExplorerSettings(IgnoreApi = true)] 46 | public IActionResult ReplaceFileForm( 47 | [FromForm] string? rpfFilePath, 48 | [FromForm] string? localFilePath) 49 | { 50 | return HandleReplacement(rpfFilePath, localFilePath); 51 | } 52 | 53 | private IActionResult HandleReplacement(string? rpfFilePath, string? localFilePath) 54 | { 55 | if (string.IsNullOrWhiteSpace(localFilePath) || !System.IO.File.Exists(localFilePath)) 56 | return BadRequest("Invalid or missing localFilePath."); 57 | 58 | if (string.IsNullOrWhiteSpace(rpfFilePath)) 59 | return BadRequest("Invalid or missing rpfFilePath."); 60 | 61 | try 62 | { 63 | if (!_rpfService.TryResolveEntryPath(rpfFilePath, out var rpfFile, out var targetDir, out var fileName, out var resolveError)) 64 | { 65 | _logger.LogWarning("Failed to resolve RPF path: {Error}", resolveError); 66 | return BadRequest($"Failed to resolve RPF path: {resolveError}"); 67 | } 68 | 69 | if (targetDir == null) 70 | { 71 | _logger.LogWarning("Target directory is null."); 72 | return BadRequest("Target directory is null."); 73 | } 74 | 75 | if (string.IsNullOrWhiteSpace(fileName)) 76 | { 77 | _logger.LogWarning("File name is null or empty."); 78 | return BadRequest("File name is null or empty."); 79 | } 80 | 81 | var localFileBytes = System.IO.File.ReadAllBytes(localFilePath); 82 | var newEntry = RpfFile.CreateFile(targetDir, fileName, localFileBytes); 83 | 84 | _logger.LogInformation("File replaced successfully: {Path}", newEntry.Path); 85 | return Ok(new { message = "File replaced successfully.", path = newEntry.Path }); 86 | } 87 | catch (InvalidOperationException ex) 88 | { 89 | return StatusCode(503, new { 90 | error = "Service unavailable", 91 | message = ex.Message, 92 | solution = "Use /api/set-config to configure a valid GTA path" 93 | }); 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Services/ConfigService.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.API.Models; 2 | using System.Text.Json; 3 | 4 | namespace CodeWalker.API.Services 5 | { 6 | public class ConfigService 7 | { 8 | private readonly string ConfigFilePath; 9 | private ApiConfig _config = new(); 10 | private string _lastGtaPath = ""; 11 | 12 | // Event to notify when GTA path changes 13 | public event Action? GtaPathChanged; 14 | 15 | public ConfigService() 16 | { 17 | ConfigFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Config", "userconfig.json"); 18 | Load(); // Load from disk on startup 19 | _lastGtaPath = _config.GTAPath; // Store initial GTA path 20 | } 21 | 22 | public ApiConfig Get() => _config; 23 | 24 | public void Set(ApiConfig config) 25 | { 26 | var oldGtaPath = _config.GTAPath; 27 | _config = config; 28 | Save(); 29 | 30 | // Check if GTA path has changed 31 | if (!string.Equals(oldGtaPath, config.GTAPath, StringComparison.OrdinalIgnoreCase)) 32 | { 33 | _lastGtaPath = config.GTAPath; 34 | GtaPathChanged?.Invoke(config.GTAPath); 35 | } 36 | } 37 | 38 | public bool HasGtaPathChanged() 39 | { 40 | return !string.Equals(_lastGtaPath, _config.GTAPath, StringComparison.OrdinalIgnoreCase); 41 | } 42 | 43 | public string GetCurrentGtaPath() => _config.GTAPath; 44 | 45 | private void Save() 46 | { 47 | try 48 | { 49 | var dir = Path.GetDirectoryName(ConfigFilePath); 50 | if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) 51 | Directory.CreateDirectory(dir); 52 | 53 | var json = JsonSerializer.Serialize(_config, new JsonSerializerOptions { WriteIndented = true }); 54 | File.WriteAllText(ConfigFilePath, json); 55 | Console.WriteLine($"[CONFIG] ✅ Saved to {ConfigFilePath}"); 56 | } 57 | catch (Exception ex) 58 | { 59 | Console.WriteLine($"[CONFIG] ❌ Failed to save config: {ex.Message}"); 60 | } 61 | } 62 | 63 | private void Load() 64 | { 65 | try 66 | { 67 | Console.WriteLine($"[CONFIG] Looking for config at: {Path.GetFullPath(ConfigFilePath)}"); 68 | 69 | if (!File.Exists(ConfigFilePath)) 70 | { 71 | Console.WriteLine($"[CONFIG] ❌ File missing: {ConfigFilePath}"); 72 | return; 73 | } 74 | 75 | var json = File.ReadAllText(ConfigFilePath); 76 | Console.WriteLine($"[CONFIG] Raw JSON: {json}"); 77 | 78 | var loadedConfig = JsonSerializer.Deserialize(json); 79 | if (loadedConfig == null) 80 | { 81 | Console.WriteLine($"[CONFIG] ❌ Failed to parse config."); 82 | return; 83 | } 84 | 85 | _config = loadedConfig; 86 | Console.WriteLine($"[CONFIG] ✅ Loaded config from {ConfigFilePath}"); 87 | Console.WriteLine($"[CONFIG] GTAPath = {_config.GTAPath}"); 88 | 89 | if (string.IsNullOrWhiteSpace(_config.GTAPath)) 90 | { 91 | Console.Error.WriteLine($"[CONFIG] ❌ GTAPath is null or empty. Cannot continue."); 92 | Environment.Exit(1); // Hard stop 93 | } 94 | } 95 | catch (Exception ex) 96 | { 97 | Console.WriteLine($"[CONFIG] ❌ Failed to load config: {ex.Message}"); 98 | } 99 | } 100 | 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CodeWalker API 2 | 3 | CodeWalker API is a .NET 9 web API that allows extracting, converting, and interacting with GTA V assets using CodeWalker's libraries. 4 | 5 | ## 📥 Download & Install 6 | 7 | You can download the latest pre-built release from the [Releases](https://github.com/flobros/CodeWalker.API/releases) page. Extract and run the API without needing to build it manually. 8 | 9 | ## 🔧 Configuration 10 | 11 | The API now reads its configuration from a `Config/userconfig.json` file located in the installation directory. You can modify this file to customize paths and ports used by the API. 12 | 13 | ### Example `Config/userconfig.json`: 14 | 15 | ```json 16 | { 17 | "CodewalkerOutputDir": "C:\\GTA_FILES\\cw_out", 18 | "BlenderOutputDir": "C:\\GTA_FILES\\blender_out", 19 | "FivemOutputDir": "C:\\GTA_FILES\\fivem_out", 20 | "RpfArchivePath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V\\modstore\\new.rpf", 21 | "GTAPath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V", 22 | "Port": 5555 23 | } 24 | ``` 25 | 26 | ## 🔄 Service Reloading 27 | 28 | The API now supports automatic and manual service reloading when the GTA path changes: 29 | 30 | ### Automatic Reload 31 | When you update the `GTAPath` configuration via the API, the system automatically: 32 | - Detects the GTA path change 33 | - Reloads RPF decryption keys from the new path 34 | - **Immediately recreates all services** (RpfService, GameFileCache) 35 | - **Preheats the new services** (loads RPF entries and caches) 36 | - Preloads known meta types for optimal performance 37 | 38 | ### Manual Reload 39 | You can manually trigger a service reload using the `/api/reload-services` endpoint: 40 | 41 | ```bash 42 | POST http://localhost:5555/api/reload-services 43 | ``` 44 | 45 | This is useful when you want to ensure all services are using the latest configuration immediately. 46 | 47 | ### Service Status 48 | Check the current service status using the `/api/service-status` endpoint: 49 | 50 | ```bash 51 | GET http://localhost:5555/api/service-status 52 | ``` 53 | 54 | This returns the current GTA path, reload version, and service readiness status. 55 | 56 | ### Graceful Startup with Invalid GTA Path 57 | The API now starts gracefully even when the initial GTA path is invalid: 58 | 59 | - **No Hard Exit**: The API will start and run even with an invalid GTA path 60 | - **Helpful Messages**: Clear error messages guide users to fix the configuration 61 | - **Service Status**: Use `/api/service-status` to check if services are ready 62 | - **Easy Fix**: Use `/api/set-config` to set a valid GTA path and automatically reload services 63 | 64 | When the GTA path is invalid, API endpoints will return a `503 Service Unavailable` status with helpful error messages and instructions on how to fix the issue. 65 | 66 | ## 🚀 Running the API 67 | 68 | To start the API, simply execute: 69 | 70 | ```sh 71 | CodeWalker.API.exe 72 | ``` 73 | 74 | The API will run on the configured port (default: `5555`). 75 | 76 | ## 🛠 Features 77 | 78 | - Extract and convert GTA V files (YDR, YTD, etc.) 79 | - Export models to XML 80 | - Extract textures 81 | - Search for files within RPF archives 82 | - Import XML or raw files back into RPF 83 | - Replace existing files in RPFs 84 | - **Automatic service reloading when GTA path changes** 85 | - **Manual service reloading via API endpoint** 86 | 87 | ## 🌐 Swagger UI (Recommended for Testing) 88 | 89 | Open the Swagger UI in your browser to test endpoints interactively: 90 | 91 | ``` 92 | http://localhost:5555 93 | ``` 94 | 95 | ## 📝 Example Requests 96 | 97 | (See detailed examples for downloading, importing, replacing, and searching files in the original request) 98 | 99 | ## 📜 License 100 | 101 | This project is released under the MIT License. 102 | 103 | ## 🤝 Contributing 104 | 105 | Pull requests are welcome! If you encounter issues, feel free to open an [issue](https://github.com/flobros/CodeWalker.API/issues). -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using CodeWalker.GameFiles; 4 | using CodeWalker.API.Services; 5 | using Microsoft.AspNetCore.Builder; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.OpenApi.Models; 11 | using CodeWalker.API.Models; 12 | 13 | // ✅ Create the builder 14 | var builder = WebApplication.CreateBuilder(args); 15 | 16 | // ✅ (No need to load appsettings.json manually!) 17 | 18 | // ✅ Register ConfigService 19 | builder.Services.AddSingleton(); 20 | 21 | // ✅ Register ServiceFactory 22 | builder.Services.AddSingleton(); 23 | 24 | // ✅ Register ReloadableServiceContainer 25 | builder.Services.AddSingleton(); 26 | 27 | // ✅ Logging setup 28 | builder.Logging.ClearProviders(); 29 | builder.Logging.AddConsole(); 30 | builder.Logging.AddDebug(); 31 | 32 | // ✅ Register services 33 | builder.Services.AddEndpointsApiExplorer(); 34 | builder.Services.AddSwaggerGen(c => 35 | { 36 | c.SwaggerDoc("v1", new OpenApiInfo { Title = "CodeWalker API", Version = "v1" }); 37 | c.EnableAnnotations(); 38 | }); 39 | builder.Services.AddControllers() 40 | .AddJsonOptions(options => 41 | { 42 | options.JsonSerializerOptions.PropertyNamingPolicy = null; // Use PascalCase 43 | }); 44 | 45 | // ✅ Register RpfService as transient (will be managed by ReloadableServiceContainer) 46 | builder.Services.AddTransient(serviceProvider => 47 | { 48 | var container = serviceProvider.GetRequiredService(); 49 | var logger = serviceProvider.GetRequiredService>(); 50 | return container.GetRpfService(logger); 51 | }); 52 | 53 | // ✅ Register GameFileCache as transient (will be managed by ReloadableServiceContainer) 54 | builder.Services.AddTransient(serviceProvider => 55 | { 56 | var container = serviceProvider.GetRequiredService(); 57 | return container.GetGameFileCache(); 58 | }); 59 | 60 | // ✅ Build the app 61 | var app = builder.Build(); 62 | 63 | // ✅ Get config after app built 64 | var configService = app.Services.GetRequiredService(); 65 | var config = configService.Get(); 66 | string gtaPath = config.GTAPath; 67 | int port = config.Port; 68 | 69 | if (port == 0) 70 | { 71 | Console.WriteLine("[WARN] Port is set to 0. Using default port 5555..."); 72 | port = 5555; 73 | } 74 | 75 | // ✅ Check GTA V directory but don't exit if invalid 76 | bool gtaPathValid = false; 77 | if (!string.IsNullOrWhiteSpace(gtaPath)) 78 | { 79 | if (Directory.Exists(gtaPath)) 80 | { 81 | gtaPathValid = true; 82 | Console.WriteLine($"[INFO] GTA V directory found at {gtaPath}"); 83 | } 84 | else 85 | { 86 | Console.WriteLine($"[WARN] GTA V directory not found at {gtaPath}"); 87 | Console.WriteLine("[INFO] API will start but services will not be initialized until a valid GTA path is configured."); 88 | } 89 | } 90 | else 91 | { 92 | Console.WriteLine("[WARN] GTA path is not configured."); 93 | Console.WriteLine("[INFO] API will start but services will not be initialized until a valid GTA path is configured."); 94 | } 95 | 96 | // ✅ Try to load RPF decryption keys if GTA path is valid 97 | if (gtaPathValid) 98 | { 99 | try 100 | { 101 | Console.WriteLine("[INFO] Loading RPF decryption keys..."); 102 | GTA5Keys.LoadFromPath(gtaPath); 103 | Console.WriteLine("[INFO] RPF decryption keys loaded successfully."); 104 | } 105 | catch (Exception ex) 106 | { 107 | Console.WriteLine($"[WARN] Failed to load RPF keys: {ex.Message}"); 108 | Console.WriteLine("[INFO] API will start but services will not be initialized until a valid GTA path is configured."); 109 | gtaPathValid = false; 110 | } 111 | } 112 | 113 | // ✅ Bind the server to the configured port 114 | app.Urls.Add($"http://0.0.0.0:{port}"); 115 | 116 | // ✅ Logging API startup 117 | var logger = app.Services.GetRequiredService>(); 118 | logger.LogInformation($"API is starting on port {port}..."); 119 | 120 | if (!gtaPathValid) 121 | { 122 | logger.LogWarning("API is starting with invalid GTA path. Use /api/set-config to configure a valid GTA path."); 123 | Console.WriteLine("[INFO] API is ready. Use the config endpoints to set a valid GTA path."); 124 | } 125 | 126 | // ✅ Swagger setup 127 | app.UseSwagger(); 128 | app.UseSwaggerUI(c => 129 | { 130 | c.SwaggerEndpoint("/swagger/v1/swagger.json", "CodeWalker API v1"); 131 | c.RoutePrefix = ""; // Swagger available at root 132 | }); 133 | app.UseRouting(); 134 | app.UseAuthorization(); 135 | app.MapControllers(); 136 | 137 | // ✅ Only preheat if GTA path is valid 138 | if (gtaPathValid) 139 | { 140 | try 141 | { 142 | var rpfService = app.Services.GetRequiredService(); 143 | int count = rpfService.Preheat(); 144 | 145 | var gameFileCache = app.Services.GetRequiredService(); 146 | Console.WriteLine("[Startup] Preloading cache with known meta types..."); 147 | // Preload by hash 148 | uint hash = JenkHash.GenHash("prop_alien_egg_01"); 149 | var ydr = gameFileCache.GetYdr(hash); 150 | if (ydr != null) 151 | Console.WriteLine("[Startup] YDR preloaded successfully."); 152 | else 153 | Console.WriteLine("[Startup] YDR not found in archive."); 154 | 155 | Console.WriteLine("[Startup] Archetype dict contains: " + gameFileCache.GetArchetype(hash)?.Name); 156 | 157 | logger.LogInformation($"[Startup] Preheated RPF with {count} entries."); 158 | } 159 | catch (Exception ex) 160 | { 161 | Console.WriteLine($"[Startup ERROR] Cache preloading failed: {ex.Message}"); 162 | logger.LogError(ex, "[Startup] Failed to preheat services"); 163 | } 164 | } 165 | else 166 | { 167 | Console.WriteLine("[INFO] Skipping service preheating due to invalid GTA path."); 168 | Console.WriteLine("[INFO] Services will be initialized when a valid GTA path is configured."); 169 | } 170 | 171 | // ✅ Run the app 172 | app.Run(); 173 | -------------------------------------------------------------------------------- /CodeWalker.API.http: -------------------------------------------------------------------------------- 1 | @CodeWalkerAPI_HostAddress = http://localhost:5555 2 | 3 | ### ✅ With XML Conversion (Downloads files and converts to XML) 4 | # This request downloads the specified YDR files and converts them to XML before saving. 5 | GET {{CodeWalkerAPI_HostAddress}}/api/download-files?fullPaths=x64c.rpf\levels\gta5\props\lev_des\lev_des.rpf\prop_alien_egg_01.ydr&fullPaths=modstore\new.rpf\prop_alien_egg_01.ydr&xml=true&outputFolderPath=C:\GTA_YDR_FILES\out 6 | Accept: application/json 7 | 8 | ### ✅ Without XML Conversion (Downloads files as they are) 9 | # This request downloads the specified YDR files without converting them to XML. 10 | GET {{CodeWalkerAPI_HostAddress}}/api/download-files?fullPaths=x64c.rpf\levels\gta5\props\lev_des\lev_des.rpf\prop_alien_egg_01.ydr&fullPaths=modstore\new.rpf\prop_alien_egg_01.ydr&xml=false&outputFolderPath=C:\GTA_FILES\fivem_out 11 | Accept: application/json 12 | 13 | ### ✅ Form Import with outputFolder, with xml conversion 14 | POST {{CodeWalkerAPI_HostAddress}}/api/import 15 | Content-Type: application/x-www-form-urlencoded 16 | 17 | xml=true&filePaths=C:\GTA_FILES\cw_out\prop_alien_egg_01.ydr.xml&filePaths=C:\GTA_FILES\cw_out\ap1_02_planes003.ydr.xml&rpfArchivePath=C:\Program Files\Rockstar Games\Grand Theft Auto V\modstore\new.rpf\some&outputFolder=C:\GTA_FILES\fivem_out 18 | 19 | ### ✅ JSON Import with outputFolder, with XML conversion 20 | POST {{CodeWalkerAPI_HostAddress}}/api/import 21 | Content-Type: application/json 22 | 23 | { 24 | "xml": true, 25 | "filePaths": [ 26 | "C:\\GTA_FILES\\cw_out\\prop_alien_egg_01.ydr.xml", 27 | "C:\\GTA_FILES\\cw_out\\ap1_02_planes003.ydr.xml" 28 | ], 29 | "rpfArchivePath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V\\modstore\\new.rpf\\some", 30 | "outputFolder": "C:\\GTA_FILES\\fivem_out" 31 | } 32 | 33 | ### ✅ Form Import with outputFolder, no xml conversion 34 | POST {{CodeWalkerAPI_HostAddress}}/api/import 35 | Content-Type: application/x-www-form-urlencoded 36 | 37 | xml=false&filePaths=C:\GTA_FILES\fivem_out\prop_alien_egg_01.ydr&filePaths=C:\GTA_FILES\fivem_out\cs4_02_airplanes.ydr&rpfArchivePath=C:\Program Files\Rockstar Games\Grand Theft Auto V\modstore\new.rpf&outputFolder=C:\GTA_FILES\fivem_out 38 | 39 | ### ✅ JSON Import with outputFolder, no xml conversion 40 | POST {{CodeWalkerAPI_HostAddress}}/api/import 41 | Content-Type: application/json 42 | 43 | { 44 | "xml": false, 45 | "filePaths": [ 46 | "C:\\GTA_FILES\\fivem_out\\prop_alien_egg_01.ydr", 47 | "C:\\GTA_FILES\\fivem_out\\cs4_02_airplanes.ydr" 48 | ], 49 | "rpfArchivePath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V\\modstore\\new.rpf", 50 | "outputFolder": "C:\\GTA_FILES\\fivem_out" 51 | } 52 | 53 | 54 | ### ✅ Search for Files in RPF Archives 55 | # This request searches for files in the RPF archive that match the given query. 56 | GET {{CodeWalkerAPI_HostAddress}}/api/search-file?filename=prop_alien_egg_01 57 | Accept: application/json 58 | 59 | ### ✅ Form replace File in subfolder of RPF 60 | POST {{CodeWalkerAPI_HostAddress}}/api/replace-file 61 | Content-Type: application/x-www-form-urlencoded 62 | 63 | rpfFilePath=modstore/new.rpf/some/some.rpf/some&localFilePath=C:\GTA_FILES\fivem_out\prop_alien_egg_01.ydr 64 | 65 | ### ✅ JSON replace File in subfolder of RPF 66 | POST {{CodeWalkerAPI_HostAddress}}/api/replace-file 67 | Content-Type: application/json 68 | 69 | { 70 | "rpfFilePath": "modstore/new.rpf/some/some.rpf/some", 71 | "localFilePath": "C:\\GTA_FILES\\fivem_out\\prop_alien_egg_01.ydr" 72 | } 73 | 74 | ### ✅ Form Set Config 75 | POST {{CodeWalkerAPI_HostAddress}}/api/set-config 76 | Content-Type: application/x-www-form-urlencoded 77 | 78 | CodewalkerOutputDir=C:\GTA_FILES\cw_out&BlenderOutputDir=C:\GTA_FILES\blender_out&FivemOutputDir=C:\FXServer\txData\QboxProject_C81BA2.base\resources\[standalone]\test_map\stream\&RpfArchivePath=C:\Program Files\Rockstar Games\Grand Theft Auto V\modstore\new.rpf>APath=C:\Program Files\Rockstar Games\Grand Theft Auto V&Port=5555 79 | 80 | 81 | ### ✅ JSON Set Config 82 | POST {{CodeWalkerAPI_HostAddress}}/api/set-config 83 | Content-Type: application/json 84 | 85 | { 86 | "CodewalkerOutputDir": "C:\\GTA_FILES\\cw_out", 87 | "BlenderOutputDir": "C:\\GTA_FILES\\blender_out", 88 | "FivemOutputDir": "C:\\GTA_FILES\\fivem_out", 89 | "RpfArchivePath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V\\modstore\\new.rpf", 90 | "GTAPath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V", 91 | "Port": 5555 92 | } 93 | 94 | ### ✅ Reload Services (Manual Trigger) 95 | # This request manually triggers a reload of all services (RpfService, GameFileCache, etc.) 96 | # This is useful when you've changed the GTA path and want to ensure all services are using the new path. 97 | POST {{CodeWalkerAPI_HostAddress}}/api/reload-services 98 | Accept: application/json 99 | 100 | ### ✅ Get Service Status 101 | # This request retrieves the current service status including reload version and GTA path. 102 | GET {{CodeWalkerAPI_HostAddress}}/api/service-status 103 | Accept: application/json 104 | 105 | ### ✅ Example: Service Status with Invalid GTA Path 106 | # When the GTA path is invalid, you'll get a response like: 107 | # { 108 | # "gtaPath": "C:\\Invalid\\Path", 109 | # "servicesReady": false, 110 | # "statusMessage": "GTA V directory not found at C:\\Invalid\\Path", 111 | # "reloadVersion": 0, 112 | # "timestamp": "2024-01-01T12:00:00.000Z" 113 | # } 114 | 115 | ### ✅ Example: Service Status with Valid GTA Path 116 | # When the GTA path is valid, you'll get a response like: 117 | # { 118 | # "gtaPath": "C:\\Program Files\\Rockstar Games\\Grand Theft Auto V", 119 | # "servicesReady": true, 120 | # "statusMessage": "Services are ready", 121 | # "reloadVersion": 1, 122 | # "timestamp": "2024-01-01T12:00:00.000Z" 123 | # } 124 | 125 | ### ✅ Example: Error Response When GTA Path is Invalid 126 | # When you try to use an endpoint with an invalid GTA path, you'll get: 127 | # { 128 | # "error": "Service unavailable", 129 | # "message": "GTA V directory not found at C:\\Invalid\\Path. Please use /api/set-config to set a valid GTA path.", 130 | # "solution": "Use /api/set-config to configure a valid GTA path" 131 | # } 132 | 133 | ### ✅ Jenkins Hash Generation (UTF8 default) 134 | GET {{CodeWalkerAPI_HostAddress}}/api/hash/jenkins?input=prop_alien_egg_01 135 | Accept: application/json 136 | 137 | ### ✅ Jenkins Hash Generation (ASCII encoding) 138 | GET {{CodeWalkerAPI_HostAddress}}/api/hash/jenkins?input=prop_alien_egg_01&encoding=1 139 | Accept: application/json 140 | 141 | -------------------------------------------------------------------------------- /Services/RpfService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using CodeWalker.API.Services; 6 | using CodeWalker.API.Utils; 7 | using CodeWalker.GameFiles; 8 | using Microsoft.Extensions.Logging; 9 | 10 | public class RpfService 11 | { 12 | private RpfManager _rpfManager; 13 | private readonly ILogger _logger; 14 | private readonly ConfigService _configService; 15 | 16 | public RpfService(ILogger logger, ConfigService configService) 17 | { 18 | _logger = logger; 19 | _configService = configService; 20 | InitializeRpfManager(); 21 | } 22 | 23 | private void InitializeRpfManager() 24 | { 25 | var cfg = _configService.Get(); 26 | string gtaPath = cfg.GTAPath; 27 | bool isGen9 = cfg.Gen9; 28 | 29 | _rpfManager = new RpfManager(); 30 | _rpfManager.Init(gtaPath, isGen9, Console.WriteLine, Console.Error.WriteLine); 31 | 32 | _logger.LogInformation("[RpfService] Initialized with Gen9 = {Gen9}, GTA Path = {GtaPath}", isGen9, gtaPath); 33 | } 34 | 35 | public void Reload() 36 | { 37 | _logger.LogInformation("[RpfService] Reloading with new GTA path..."); 38 | InitializeRpfManager(); 39 | _logger.LogInformation("[RpfService] Reload completed. Entry count: {Count}", _rpfManager.EntryDict.Count); 40 | } 41 | 42 | private static string NormalizePath(string path) 43 | { 44 | return path.Replace('\\', '/').Trim(); 45 | } 46 | 47 | public List SearchFile(string filename) 48 | { 49 | var results = new List(); 50 | foreach (var entry in _rpfManager.EntryDict.Values) 51 | { 52 | if (entry.Name.Contains(filename, StringComparison.OrdinalIgnoreCase)) 53 | { 54 | results.Add(entry.Path); 55 | } 56 | } 57 | return results; 58 | } 59 | 60 | public byte[] ExtractFile(string filename) 61 | { 62 | var entry = _rpfManager.GetEntry(filename); 63 | if (entry is RpfFileEntry fileEntry) 64 | { 65 | _logger.LogDebug("Extracting {FilePath}...", fileEntry.Path); 66 | return fileEntry.File.ExtractFile(fileEntry); 67 | } 68 | throw new FileNotFoundException($"File '{filename}' not found."); 69 | } 70 | 71 | public (byte[] fileBytes, RpfFileEntry entry)? ExtractFileWithEntry(string fullRpfPath) 72 | { 73 | var entry = _rpfManager.GetEntry(fullRpfPath); 74 | if (entry is RpfFileEntry fileEntry) 75 | { 76 | _logger.LogDebug("[MATCH] Found: {FilePath}", fileEntry.Path); 77 | return (fileEntry.File.ExtractFile(fileEntry), fileEntry); 78 | } 79 | _logger.LogWarning("[DEBUG] File not found in RPF: {FullPath}", fullRpfPath); 80 | return null; 81 | } 82 | 83 | public RpfEntry? GetEntry(string path) 84 | { 85 | return _rpfManager.GetEntry(path); 86 | } 87 | 88 | public RpfFile? GetParentRpfFile(RpfEntry entry) 89 | { 90 | return entry?.File; 91 | } 92 | 93 | public RpfDirectoryEntry EnsureDirectoryPath(RpfDirectoryEntry root, string internalPath) 94 | { 95 | var parts = internalPath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); 96 | RpfDirectoryEntry current = root; 97 | foreach (var part in parts) 98 | { 99 | var existing = current.Directories.FirstOrDefault(d => d.Name.Equals(part, StringComparison.OrdinalIgnoreCase)); 100 | if (existing != null) 101 | { 102 | current = existing; 103 | continue; 104 | } 105 | _logger.LogInformation("[CREATE] Creating directory '{Dir}' in '{Parent}'", part, current.Path); 106 | var newDir = RpfFile.CreateDirectory(current, part); 107 | current = newDir; 108 | } 109 | return current; 110 | } 111 | 112 | private string ToVirtualPath(string fullPath) 113 | { 114 | var gtaPath = PathUtils.NormalizePath(_configService.Get().GTAPath); // `/` 115 | var normalized = PathUtils.NormalizePath(fullPath); // `/` 116 | 117 | if (normalized.StartsWith(gtaPath, StringComparison.OrdinalIgnoreCase)) 118 | normalized = normalized.Substring(gtaPath.Length).TrimStart('/'); 119 | 120 | return PathUtils.NormalizePath(normalized, useBackslashes: true); // final lookup version 121 | } 122 | 123 | 124 | public bool TryResolveEntryPath(string fullPath, out RpfFile? rpfFile, out RpfDirectoryEntry? targetDir, out string? fileName, out string? error) 125 | { 126 | rpfFile = null; 127 | targetDir = null; 128 | fileName = null; 129 | error = null; 130 | 131 | try 132 | { 133 | var normalized = NormalizePath(fullPath); 134 | _logger.LogDebug("[TryResolve] Normalized path: {Path}", normalized); 135 | 136 | var virtualPath = ToVirtualPath(fullPath); // now backslash-correct 137 | 138 | // Case 1: It’s a folder 139 | var entry = _rpfManager.GetEntry(virtualPath); 140 | if (entry is RpfDirectoryEntry dirEntry) 141 | { 142 | rpfFile = dirEntry.File; 143 | targetDir = dirEntry; 144 | return true; 145 | } 146 | 147 | // Case 2: It’s an RPF file 148 | if (virtualPath.EndsWith(".rpf", StringComparison.OrdinalIgnoreCase)) 149 | { 150 | _logger.LogDebug("[TryResolve] Trying FindRpfFile({Path})", virtualPath); 151 | var archive = _rpfManager.FindRpfFile(virtualPath); 152 | if (archive != null) 153 | { 154 | rpfFile = archive; 155 | targetDir = rpfFile?.Root; 156 | return true; 157 | } 158 | } 159 | 160 | error = $"Entry not found or not a valid target: {entry?.GetType().Name ?? "null"}"; 161 | _logger.LogWarning("[TryResolve] {Error}", error); 162 | return false; 163 | } 164 | catch (Exception ex) 165 | { 166 | error = ex.Message; 167 | _logger.LogError(ex, "[TryResolve] Unexpected error"); 168 | return false; 169 | } 170 | } 171 | 172 | public int Preheat() 173 | { 174 | return _rpfManager.EntryDict.Count; 175 | } 176 | 177 | public RpfManager Manager => _rpfManager; 178 | 179 | } 180 | -------------------------------------------------------------------------------- /Controllers/ConfigController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Swashbuckle.AspNetCore.Annotations; 3 | using CodeWalker.API.Models; 4 | using CodeWalker.API.Services; 5 | 6 | namespace CodeWalker.API.Controllers 7 | { 8 | [ApiController] 9 | [Route("api")] 10 | public class ConfigController : ControllerBase 11 | { 12 | private readonly ConfigService _configService; 13 | private readonly ReloadableServiceContainer _serviceContainer; 14 | 15 | public ConfigController(ConfigService configService, ReloadableServiceContainer serviceContainer) 16 | { 17 | _configService = configService; 18 | _serviceContainer = serviceContainer; 19 | } 20 | 21 | [HttpPost("set-config")] 22 | [Consumes("application/json")] 23 | [SwaggerOperation(Summary = "Set config paths (JSON)", Description = "Updates folder paths using a JSON body.")] 24 | public IActionResult SetConfigJson([FromBody] ApiConfig updated) 25 | { 26 | return MergeAndSaveConfig(updated); 27 | } 28 | 29 | [HttpPost("set-config")] 30 | [Consumes("application/x-www-form-urlencoded")] 31 | [ApiExplorerSettings(IgnoreApi = true)] 32 | public IActionResult SetConfigForm( 33 | [FromForm] string? CodewalkerOutputDir, 34 | [FromForm] string? BlenderOutputDir, 35 | [FromForm] string? FivemOutputDir, 36 | [FromForm] string? RpfArchivePath, 37 | [FromForm] string? GTAPath, 38 | [FromForm] int? Port) 39 | { 40 | var current = _configService.Get(); 41 | var merged = new ApiConfig 42 | { 43 | CodewalkerOutputDir = CodewalkerOutputDir ?? current.CodewalkerOutputDir, 44 | BlenderOutputDir = BlenderOutputDir ?? current.BlenderOutputDir, 45 | FivemOutputDir = FivemOutputDir ?? current.FivemOutputDir, 46 | RpfArchivePath = RpfArchivePath ?? current.RpfArchivePath, 47 | GTAPath = GTAPath ?? current.GTAPath, 48 | Port = (Port.HasValue && Port != 0) ? Port.Value : current.Port 49 | }; 50 | 51 | return MergeAndSaveConfig(merged); 52 | } 53 | 54 | private IActionResult MergeAndSaveConfig(ApiConfig merged) 55 | { 56 | var oldConfig = _configService.Get(); 57 | var oldGtaPath = oldConfig.GTAPath; 58 | 59 | _configService.Set(merged); 60 | 61 | // Check if GTA path changed 62 | if (!string.Equals(oldGtaPath, merged.GTAPath, StringComparison.OrdinalIgnoreCase)) 63 | { 64 | return Ok(new { 65 | message = "Configuration updated successfully. GTA path changed - services are being reloaded automatically.", 66 | gtaPathChanged = true, 67 | oldGtaPath = oldGtaPath, 68 | newGtaPath = merged.GTAPath, 69 | reloadVersion = _serviceContainer.GetReloadVersion() 70 | }); 71 | } 72 | 73 | return Ok(new { message = "Configuration updated successfully." }); 74 | } 75 | 76 | [HttpGet("get-config")] 77 | [SwaggerOperation(Summary = "Get config paths", Description = "Retrieves the currently configured folder paths.")] 78 | [SwaggerResponse(200, "Current config", typeof(ApiConfig))] 79 | public IActionResult GetConfig() 80 | { 81 | return Ok(_configService.Get()); 82 | } 83 | 84 | [HttpPost("reset-config")] 85 | [SwaggerOperation(Summary = "Reset config paths", Description = "Resets the backend folder configuration to default values.")] 86 | [SwaggerResponse(200, "Configuration reset to defaults")] 87 | public IActionResult ResetConfig() 88 | { 89 | _configService.Set(new ApiConfig()); 90 | return Ok(new { message = "Configuration reset to defaults." }); 91 | } 92 | 93 | [HttpGet("service-status")] 94 | [SwaggerOperation(Summary = "Get service status", Description = "Retrieves the current service status including reload version and GTA path.")] 95 | [SwaggerResponse(200, "Service status")] 96 | public IActionResult GetServiceStatus() 97 | { 98 | var config = _configService.Get(); 99 | var gtaPath = config.GTAPath; 100 | 101 | bool servicesReady = false; 102 | string statusMessage = ""; 103 | 104 | if (string.IsNullOrWhiteSpace(gtaPath)) 105 | { 106 | statusMessage = "GTA path is not configured"; 107 | } 108 | else if (!Directory.Exists(gtaPath)) 109 | { 110 | statusMessage = $"GTA V directory not found at {gtaPath}"; 111 | } 112 | else 113 | { 114 | try 115 | { 116 | // Try to get a service to see if they're working 117 | var testService = _serviceContainer.GetRpfService(new LoggerFactory().CreateLogger()); 118 | servicesReady = true; 119 | statusMessage = "Services are ready"; 120 | } 121 | catch (InvalidOperationException ex) 122 | { 123 | statusMessage = ex.Message; 124 | } 125 | } 126 | 127 | return Ok(new { 128 | gtaPath = gtaPath, 129 | servicesReady = servicesReady, 130 | statusMessage = statusMessage, 131 | reloadVersion = _serviceContainer.GetReloadVersion(), 132 | timestamp = DateTime.UtcNow 133 | }); 134 | } 135 | 136 | [HttpPost("reload-services")] 137 | [SwaggerOperation(Summary = "Reload services", Description = "Manually triggers a reload of all services (RpfService, GameFileCache, etc.) with the current configuration.")] 138 | [SwaggerResponse(200, "Services reloaded successfully")] 139 | public IActionResult ReloadServices() 140 | { 141 | var config = _configService.Get(); 142 | var gtaPath = config.GTAPath; 143 | 144 | _serviceContainer.ReloadServices(); 145 | 146 | return Ok(new { 147 | message = "Services reloaded successfully.", 148 | gtaPath = gtaPath, 149 | reloadVersion = _serviceContainer.GetReloadVersion(), 150 | timestamp = DateTime.UtcNow 151 | }); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Controllers/DownloadController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Swashbuckle.AspNetCore.Annotations; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Text; 6 | using CodeWalker.GameFiles; 7 | using CodeWalker.API.Services; // ✅ Needed for ConfigService 8 | using Microsoft.Extensions.Logging; 9 | using CodeWalker.API.Utils; 10 | 11 | namespace CodeWalker.API.Controllers 12 | { 13 | [Route("api")] 14 | [ApiController] 15 | public class DownloadController : ControllerBase 16 | { 17 | private readonly ILogger _logger; 18 | private readonly RpfService _rpfService; 19 | private readonly TextureExtractor _textureExtractor; 20 | private readonly ConfigService _configService; 21 | 22 | public DownloadController( 23 | ILogger logger, 24 | RpfService rpfService, 25 | GameFileCache gameFileCache, 26 | ILogger textureLogger, 27 | ConfigService configService) // ✅ Injected 28 | { 29 | _logger = logger; 30 | _rpfService = rpfService; 31 | _textureExtractor = new TextureExtractor(gameFileCache, textureLogger); 32 | _configService = configService; 33 | } 34 | 35 | [HttpGet("download-files")] 36 | [SwaggerOperation( 37 | Summary = "Downloads and extracts files", 38 | Description = "Extracts textures or converts YDR files to XML before saving them." 39 | )] 40 | [SwaggerResponse(200, "Successful operation", typeof(List))] 41 | [SwaggerResponse(400, "Bad request")] 42 | [SwaggerResponse(503, "Service unavailable - GTA path not configured")] 43 | public IActionResult DownloadFiles( 44 | [FromQuery, SwaggerParameter("List of full RPF paths", Required = true)] 45 | string[] fullPaths, 46 | [FromQuery] bool xml = true, 47 | [FromQuery] bool textures = true 48 | ) 49 | { 50 | if (fullPaths == null || fullPaths.Length == 0) 51 | { 52 | _logger.LogWarning("No full RPF paths provided."); 53 | return BadRequest("At least one full RPF path is required."); 54 | } 55 | 56 | var config = _configService.Get(); 57 | var codewalkerOutput = config.CodewalkerOutputDir; 58 | var blenderOutput = config.BlenderOutputDir; 59 | 60 | if (string.IsNullOrWhiteSpace(codewalkerOutput)) 61 | { 62 | _logger.LogWarning("Configured output folder path is empty."); 63 | return BadRequest("Output folder is not configured."); 64 | } 65 | 66 | Directory.CreateDirectory(codewalkerOutput); 67 | var results = new List(); 68 | 69 | try 70 | { 71 | foreach (var originalPath in fullPaths) 72 | { 73 | var fullRpfPath = PathUtils.NormalizePath(originalPath); 74 | try 75 | { 76 | var extractedFile = _rpfService.ExtractFileWithEntry(fullRpfPath); 77 | if (!extractedFile.HasValue) 78 | { 79 | _logger.LogWarning($"File '{fullRpfPath}' not found."); 80 | results.Add(new { fullRpfPath, error = $"File '{fullRpfPath}' not found." }); 81 | continue; 82 | } 83 | 84 | var (fileBytes, entry) = extractedFile.Value; 85 | string filename = Path.GetFileName(fullRpfPath); 86 | string filenameWithoutExt = Path.GetFileNameWithoutExtension(fullRpfPath); 87 | string objectFilePath = Path.Combine(codewalkerOutput, filename); 88 | 89 | if (xml) 90 | { 91 | string newFilename; 92 | string xmlData = MetaXml.GetXml(entry, fileBytes, out newFilename, codewalkerOutput); 93 | if (string.IsNullOrEmpty(xmlData)) 94 | { 95 | results.Add(new { fullRpfPath, error = $"XML export unavailable for {Path.GetExtension(fullRpfPath)}" }); 96 | continue; 97 | } 98 | 99 | string ext = Path.GetExtension(fullRpfPath)?.TrimStart('.') ?? "bin"; 100 | string xmlFilePath = Path.Combine(codewalkerOutput, $"{filenameWithoutExt}.{ext}.xml"); 101 | System.IO.File.WriteAllText(xmlFilePath, xmlData, Encoding.UTF8); 102 | results.Add(new { fullRpfPath, message = "XML saved successfully.", xmlFilePath }); 103 | } 104 | 105 | if (textures) 106 | { 107 | string textureFolder = Path.Combine(codewalkerOutput, filenameWithoutExt); 108 | Directory.CreateDirectory(textureFolder); 109 | string tempYdrPath = Path.Combine(Path.GetTempPath(), filename); 110 | System.IO.File.WriteAllBytes(tempYdrPath, fileBytes); 111 | _textureExtractor.ExtractTextures(fileBytes, entry, textureFolder); 112 | System.IO.File.Delete(tempYdrPath); 113 | 114 | results.Add(new { fullRpfPath, message = "Textures extracted successfully.", textureFolderPath = textureFolder }); 115 | 116 | // ✅ Also copy to Blender Output Dir 117 | if (!string.IsNullOrWhiteSpace(blenderOutput)) 118 | { 119 | string dest = Path.Combine(blenderOutput, filenameWithoutExt); 120 | if (Directory.Exists(dest)) Directory.Delete(dest, true); 121 | Directory.CreateDirectory(Path.GetDirectoryName(dest)!); 122 | DirUtils.CopyDirectory(textureFolder, dest); 123 | results.Add(new { fullRpfPath, message = "Textures copied to Blender output.", copiedTo = dest }); 124 | } 125 | } 126 | 127 | if (!xml) 128 | { 129 | System.IO.File.WriteAllBytes(objectFilePath, fileBytes); 130 | results.Add(new { fullRpfPath, message = "File saved successfully.", objectFilePath }); 131 | } 132 | } 133 | catch (Exception ex) 134 | { 135 | _logger.LogError($"Error processing {fullRpfPath}: {ex.Message}"); 136 | results.Add(new { fullRpfPath, error = ex.Message }); 137 | } 138 | } 139 | 140 | return Ok(results); 141 | } 142 | catch (InvalidOperationException ex) 143 | { 144 | return StatusCode(503, new { 145 | error = "Service unavailable", 146 | message = ex.Message, 147 | solution = "Use /api/set-config to configure a valid GTA path" 148 | }); 149 | } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Services/ReloadableServiceContainer.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.GameFiles; 2 | using Microsoft.Extensions.Logging; 3 | using System; 4 | using System.Threading; 5 | using System.IO; // Added for Directory.Exists 6 | 7 | namespace CodeWalker.API.Services 8 | { 9 | public class ReloadableServiceContainer 10 | { 11 | private readonly ServiceFactory _serviceFactory; 12 | private readonly ILogger _logger; 13 | private readonly ConfigService _configService; 14 | 15 | private RpfService? _rpfService; 16 | private GameFileCache? _gameFileCache; 17 | private readonly object _lock = new object(); 18 | private int _reloadVersion = 0; 19 | 20 | public ReloadableServiceContainer( 21 | ServiceFactory serviceFactory, 22 | ILogger logger, 23 | ConfigService configService) 24 | { 25 | _serviceFactory = serviceFactory; 26 | _logger = logger; 27 | _configService = configService; 28 | 29 | // Subscribe to GTA path changes 30 | _configService.GtaPathChanged += OnGtaPathChanged; 31 | } 32 | 33 | private void OnGtaPathChanged(string newGtaPath) 34 | { 35 | _logger.LogInformation("[ReloadableServiceContainer] GTA path changed to: {NewPath}", newGtaPath); 36 | ReloadServices(); 37 | } 38 | 39 | public void ReloadServices() 40 | { 41 | lock (_lock) 42 | { 43 | try 44 | { 45 | _logger.LogInformation("[ReloadableServiceContainer] Starting service reload..."); 46 | 47 | // Increment version to invalidate current services 48 | Interlocked.Increment(ref _reloadVersion); 49 | 50 | // Dispose old services if they implement IDisposable 51 | if (_rpfService is IDisposable disposableRpf) 52 | { 53 | disposableRpf.Dispose(); 54 | } 55 | if (_gameFileCache is IDisposable disposableCache) 56 | { 57 | disposableCache.Dispose(); 58 | } 59 | 60 | // Clear references 61 | _rpfService = null; 62 | _gameFileCache = null; 63 | 64 | _logger.LogInformation("[ReloadableServiceContainer] Services cleared. Creating new services immediately..."); 65 | 66 | // Immediately create new services with the updated configuration 67 | var config = _configService.Get(); 68 | string gtaPath = config.GTAPath; 69 | 70 | if (!Directory.Exists(gtaPath)) 71 | { 72 | _logger.LogError("[ReloadableServiceContainer] GTA V directory not found at {GtaPath}", gtaPath); 73 | return; 74 | } 75 | 76 | // Load RPF decryption keys 77 | try 78 | { 79 | _logger.LogInformation("[ReloadableServiceContainer] Loading RPF decryption keys..."); 80 | GTA5Keys.LoadFromPath(gtaPath); 81 | _logger.LogInformation("[ReloadableServiceContainer] RPF decryption keys loaded successfully."); 82 | } 83 | catch (Exception ex) 84 | { 85 | _logger.LogError(ex, "[ReloadableServiceContainer] Failed to load RPF keys: {Message}", ex.Message); 86 | return; 87 | } 88 | 89 | // Create new services immediately 90 | _logger.LogInformation("[ReloadableServiceContainer] Creating new RpfService..."); 91 | var loggerFactory = new LoggerFactory(); 92 | var rpfLogger = loggerFactory.CreateLogger(); 93 | _rpfService = _serviceFactory.CreateRpfService(rpfLogger); 94 | 95 | _logger.LogInformation("[ReloadableServiceContainer] Creating new GameFileCache..."); 96 | _gameFileCache = _serviceFactory.CreateGameFileCache(); 97 | 98 | // Preheat the new services 99 | _logger.LogInformation("[ReloadableServiceContainer] Preheating new services..."); 100 | int count = _rpfService.Preheat(); 101 | 102 | try 103 | { 104 | _logger.LogInformation("[ReloadableServiceContainer] Preloading cache with known meta types..."); 105 | // Preload by hash 106 | uint hash = JenkHash.GenHash("prop_alien_egg_01"); 107 | var ydr = _gameFileCache.GetYdr(hash); 108 | if (ydr != null) 109 | _logger.LogInformation("[ReloadableServiceContainer] YDR preloaded successfully."); 110 | else 111 | _logger.LogInformation("[ReloadableServiceContainer] YDR not found in archive."); 112 | 113 | _logger.LogInformation("[ReloadableServiceContainer] Archetype dict contains: " + _gameFileCache.GetArchetype(hash)?.Name); 114 | } 115 | catch (Exception ex) 116 | { 117 | _logger.LogError(ex, "[ReloadableServiceContainer] Cache preloading failed: {Message}", ex.Message); 118 | } 119 | 120 | _logger.LogInformation("[ReloadableServiceContainer] Service reload completed. Preheated RPF with {Count} entries.", count); 121 | } 122 | catch (Exception ex) 123 | { 124 | _logger.LogError(ex, "[ReloadableServiceContainer] Error during service reload: {Message}", ex.Message); 125 | } 126 | } 127 | } 128 | 129 | public RpfService GetRpfService(ILogger logger) 130 | { 131 | lock (_lock) 132 | { 133 | if (_rpfService == null) 134 | { 135 | var config = _configService.Get(); 136 | string gtaPath = config.GTAPath; 137 | 138 | if (string.IsNullOrWhiteSpace(gtaPath)) 139 | { 140 | throw new InvalidOperationException("GTA path is not configured. Please use /api/set-config to set a valid GTA path."); 141 | } 142 | 143 | if (!Directory.Exists(gtaPath)) 144 | { 145 | throw new InvalidOperationException($"GTA V directory not found at {gtaPath}. Please use /api/set-config to set a valid GTA path."); 146 | } 147 | 148 | _logger.LogInformation("[ReloadableServiceContainer] Creating new RpfService..."); 149 | _rpfService = _serviceFactory.CreateRpfService(logger); 150 | } 151 | return _rpfService; 152 | } 153 | } 154 | 155 | public GameFileCache GetGameFileCache() 156 | { 157 | lock (_lock) 158 | { 159 | if (_gameFileCache == null) 160 | { 161 | var config = _configService.Get(); 162 | string gtaPath = config.GTAPath; 163 | 164 | if (string.IsNullOrWhiteSpace(gtaPath)) 165 | { 166 | throw new InvalidOperationException("GTA path is not configured. Please use /api/set-config to set a valid GTA path."); 167 | } 168 | 169 | if (!Directory.Exists(gtaPath)) 170 | { 171 | throw new InvalidOperationException($"GTA V directory not found at {gtaPath}. Please use /api/set-config to set a valid GTA path."); 172 | } 173 | 174 | _logger.LogInformation("[ReloadableServiceContainer] Creating new GameFileCache..."); 175 | _gameFileCache = _serviceFactory.CreateGameFileCache(); 176 | } 177 | return _gameFileCache; 178 | } 179 | } 180 | 181 | public int GetReloadVersion() => _reloadVersion; 182 | 183 | public void Dispose() 184 | { 185 | _configService.GtaPathChanged -= OnGtaPathChanged; 186 | 187 | if (_rpfService is IDisposable disposableRpf) 188 | { 189 | disposableRpf.Dispose(); 190 | } 191 | if (_gameFileCache is IDisposable disposableCache) 192 | { 193 | disposableCache.Dispose(); 194 | } 195 | } 196 | } 197 | } -------------------------------------------------------------------------------- /Controllers/ImportController.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.API.Services; 2 | using CodeWalker.GameFiles; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Swashbuckle.AspNetCore.Annotations; 5 | 6 | [Route("api")] 7 | [ApiController] 8 | public class ImportController : ControllerBase 9 | { 10 | private readonly ILogger _logger; 11 | private readonly RpfService _rpfService; 12 | private readonly ConfigService _configService; 13 | 14 | public ImportController(ILogger logger, RpfService rpfService, ConfigService configService) 15 | { 16 | _logger = logger; 17 | _rpfService = rpfService; 18 | _configService = configService; 19 | } 20 | 21 | public class ImportRequest 22 | { 23 | public List? FilePaths { get; set; } 24 | public bool? Xml { get; set; } 25 | public string? RpfArchivePath { get; set; } 26 | public string? OutputFolder { get; set; } 27 | } 28 | 29 | [HttpPost("import")] 30 | [Consumes("application/json")] 31 | [SwaggerOperation( 32 | Summary = "Import files into RPF archive", 33 | Description = "Imports one or more raw or XML files into a specified RPF archive path. Converts XML to binary if requested." 34 | )] 35 | [SwaggerResponse(200, "Import result", typeof(List))] 36 | [SwaggerResponse(400, "Bad request (e.g. missing file paths or RPF path)")] 37 | [SwaggerResponse(503, "Service unavailable - GTA path not configured")] 38 | public async Task ImportJson([FromBody] ImportRequest json) 39 | { 40 | return await HandleImport(json.FilePaths, json.Xml, json.RpfArchivePath, json.OutputFolder); 41 | } 42 | 43 | [HttpPost("import")] 44 | [Consumes("application/x-www-form-urlencoded")] 45 | [ApiExplorerSettings(IgnoreApi = true)] 46 | public async Task ImportForm( 47 | [FromForm] List? filePaths, 48 | [FromForm] bool? xml, 49 | [FromForm] string? rpfArchivePath, 50 | [FromForm] string? outputFolder) 51 | { 52 | return await HandleImport(filePaths, xml, rpfArchivePath, outputFolder); 53 | } 54 | 55 | private async Task HandleImport( 56 | List? paths, 57 | bool? xml, 58 | string? rpfPath, 59 | string? outputFolder) 60 | { 61 | var config = _configService.Get(); 62 | var isXml = xml ?? false; 63 | rpfPath ??= config.RpfArchivePath; 64 | outputFolder ??= config.FivemOutputDir; 65 | 66 | _logger.LogDebug("Starting import. XML: {IsXml}, RPF: {RpfPath}, Output: {OutputFolder}", isXml, rpfPath, outputFolder); 67 | 68 | if (paths == null || paths.Count == 0) 69 | { 70 | _logger.LogDebug("No file paths provided."); 71 | return BadRequest("No files provided."); 72 | } 73 | 74 | if (string.IsNullOrWhiteSpace(rpfPath)) 75 | { 76 | _logger.LogDebug("Missing RPF path."); 77 | return BadRequest("Missing RPF archive path."); 78 | } 79 | 80 | try 81 | { 82 | if (!_rpfService.TryResolveEntryPath(rpfPath, out var rpfFile, out var targetDir, out _, out var err)) 83 | { 84 | _logger.LogDebug("Failed to resolve RPF path: {Error}", err); 85 | return BadRequest($"Failed to resolve RPF path: {err}"); 86 | } 87 | 88 | if (targetDir == null) 89 | { 90 | _logger.LogDebug("Target directory inside RPF was null."); 91 | return BadRequest("Target directory inside RPF was null."); 92 | } 93 | 94 | if (rpfFile != null) 95 | { 96 | var isGen9 = rpfFile.Encryption == RpfEncryption.NG; 97 | 98 | _logger.LogInformation("[Import] Importing to archive: {Path}", rpfFile.FilePath ?? "(unknown)"); 99 | _logger.LogInformation("[Import] Gen9 Archive: {IsGen9} | Encryption: {Encryption}", isGen9, rpfFile.Encryption); 100 | } 101 | 102 | var results = new List(); 103 | 104 | foreach (var filePath in paths) 105 | { 106 | if (!System.IO.File.Exists(filePath)) 107 | { 108 | _logger.LogDebug("File does not exist: {Path}", filePath); 109 | results.Add(new { filePath, error = "File not found." }); 110 | continue; 111 | } 112 | 113 | try 114 | { 115 | byte[] data; 116 | string modelName; 117 | string finalName; 118 | 119 | if (isXml) 120 | { 121 | _logger.LogDebug("Processing XML file: {File}", filePath); 122 | 123 | var xmlText = await System.IO.File.ReadAllTextAsync(filePath); 124 | var doc = new System.Xml.XmlDocument(); 125 | doc.LoadXml(xmlText); 126 | 127 | var format = XmlMeta.GetXMLFormat(filePath.ToLower(), out int trimLength); 128 | if (format == MetaFormat.XML) 129 | { 130 | _logger.LogDebug("Unhandled XML format for file: {File}", filePath); 131 | results.Add(new { filePath, error = "Unknown or unhandled XML format." }); 132 | continue; 133 | } 134 | 135 | var fullFilename = Path.GetFileNameWithoutExtension(filePath); 136 | modelName = fullFilename[..^trimLength]; 137 | var ext = Path.GetExtension(fullFilename); 138 | finalName = Path.ChangeExtension(modelName, ext); 139 | 140 | var texFolder = Path.Combine(Path.GetDirectoryName(filePath) ?? "", modelName); 141 | if (!Directory.Exists(texFolder)) texFolder = null; 142 | 143 | data = XmlMeta.GetData(doc, format, texFolder); 144 | 145 | if (data == null) 146 | { 147 | _logger.LogDebug("Failed to convert XML to binary for file: {File}", filePath); 148 | results.Add(new { filePath, error = "Failed to convert XML." }); 149 | continue; 150 | } 151 | 152 | _logger.LogDebug("XML converted successfully. Model: {Model}, Ext: {Ext}", modelName, ext); 153 | } 154 | else 155 | { 156 | _logger.LogDebug("Reading raw file: {File}", filePath); 157 | data = await System.IO.File.ReadAllBytesAsync(filePath); 158 | finalName = Path.GetFileName(filePath); 159 | modelName = Path.GetFileNameWithoutExtension(filePath); 160 | } 161 | 162 | _logger.LogDebug("Creating file in RPF: {FinalName} (Size: {Size} bytes)", finalName, data.Length); 163 | var entry = RpfFile.CreateFile(targetDir, finalName, data); 164 | 165 | string? outPath = null; 166 | if (!string.IsNullOrWhiteSpace(outputFolder)) 167 | { 168 | outPath = Path.Combine(outputFolder, finalName); 169 | var temp = Path.Combine(outputFolder, modelName + ".tmp"); 170 | 171 | _logger.LogDebug("Writing to temp file: {Temp}", temp); 172 | await System.IO.File.WriteAllBytesAsync(temp, data); 173 | 174 | if (System.IO.File.Exists(outPath)) 175 | { 176 | _logger.LogDebug("Deleting existing file: {Out}", outPath); 177 | System.IO.File.Delete(outPath); 178 | } 179 | 180 | System.IO.File.Move(temp, outPath); 181 | _logger.LogDebug("Moved temp to final: {Out}", outPath); 182 | } 183 | else 184 | { 185 | _logger.LogDebug("Output folder is empty, skipping write."); 186 | } 187 | 188 | results.Add(new 189 | { 190 | filePath, 191 | message = isXml ? "XML imported." : "Raw file imported.", 192 | filename = finalName, 193 | writtenTo = entry.Path, 194 | outputFilePath = outPath 195 | }); 196 | 197 | _logger.LogDebug("Import result added for: {File}", finalName); 198 | } 199 | catch (Exception ex) 200 | { 201 | _logger.LogError(ex, "Exception while processing file: {File}", filePath); 202 | results.Add(new { filePath, error = ex.Message }); 203 | } 204 | } 205 | 206 | _logger.LogDebug("Import completed. Processed {Count} file(s).", results.Count); 207 | return Ok(results); 208 | } 209 | catch (InvalidOperationException ex) 210 | { 211 | return StatusCode(503, new { 212 | error = "Service unavailable", 213 | message = ex.Message, 214 | solution = "Use /api/set-config to configure a valid GTA path" 215 | }); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Utils/TextureExtractor.cs: -------------------------------------------------------------------------------- 1 | using CodeWalker.GameFiles; 2 | using CodeWalker.Utils; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | 7 | public class TextureExtractor 8 | { 9 | private readonly GameFileCache _gameFileCache; 10 | private readonly ILogger _logger; 11 | 12 | public TextureExtractor(GameFileCache cache, ILogger logger) 13 | { 14 | _gameFileCache = cache; 15 | _logger = logger; 16 | } 17 | 18 | public void ExtractTextures(byte[] fileBytes, RpfFileEntry entry, string outputFolder) 19 | { 20 | string ext = Path.GetExtension(entry.Name)?.ToLowerInvariant() ?? string.Empty; 21 | _logger.LogInformation($"Extracting textures from: {entry.Name} (ext: {ext})"); 22 | 23 | HashSet textures = new(); 24 | HashSet missing = new(); 25 | 26 | Texture? TryGetTextureFromYtd(uint texHash, YtdFile? ytd) 27 | { 28 | if (ytd == null) return null; 29 | if (!ytd.Loaded) 30 | { 31 | _logger.LogWarning("YTD was not marked as loaded before access."); 32 | return null; 33 | } 34 | var tex = ytd.TextureDict?.Lookup(texHash); 35 | _logger.LogDebug($"▶ texHash {texHash:X8} → Found in TextureDict: {(tex != null)}"); 36 | return tex; 37 | } 38 | 39 | Texture? TryGetTexture(uint texHash, uint txdHash) 40 | { 41 | Texture? texResult = null; 42 | 43 | if (txdHash != 0) 44 | { 45 | if (_gameFileCache.YtdDict.TryGetValue(txdHash, out var ytdEntry) && ytdEntry != null) 46 | { 47 | var ytd = _gameFileCache.GetYtd(txdHash); 48 | if (ytd == null || !ytd.Loaded) 49 | { 50 | try 51 | { 52 | var data = _gameFileCache.RpfMan.GetFileData(ytdEntry.Path); 53 | ytd = new YtdFile(); 54 | ytd.Load(data, ytdEntry); 55 | ytd.Loaded = true; 56 | _logger.LogDebug($"✅ Lazily loaded YTD: {ytdEntry.Path}"); 57 | } 58 | catch (Exception ex) 59 | { 60 | _logger.LogWarning($"❌ Failed to load YTD {ytdEntry.Path}: {ex.Message}"); 61 | return null; 62 | } 63 | } 64 | texResult = TryGetTextureFromYtd(texHash, ytd); 65 | if (texResult != null) 66 | return texResult; 67 | } 68 | } 69 | 70 | var visited = new HashSet(); 71 | var ptxdhash = _gameFileCache.TryGetParentYtdHash(txdHash); 72 | while (ptxdhash != 0 && texResult == null && !visited.Contains(ptxdhash)) 73 | { 74 | visited.Add(ptxdhash); 75 | texResult = TryGetTexture(texHash, ptxdhash); 76 | if (texResult == null) 77 | { 78 | ptxdhash = _gameFileCache.TryGetParentYtdHash(ptxdhash); 79 | } 80 | } 81 | 82 | if (texResult == null) 83 | { 84 | var fallbackYtd = _gameFileCache.TryGetTextureDictForTexture(texHash); 85 | texResult = TryGetTextureFromYtd(texHash, fallbackYtd); 86 | } 87 | 88 | if (texResult == null) 89 | { 90 | foreach (var fallbackEntry in _gameFileCache.YtdDict.Values) 91 | { 92 | if (fallbackEntry == null) continue; 93 | try 94 | { 95 | var data = _gameFileCache.RpfMan.GetFileData(fallbackEntry.Path); 96 | var fallbackYtd = new YtdFile(); 97 | fallbackYtd.Load(data, fallbackEntry); 98 | fallbackYtd.Loaded = true; 99 | texResult = TryGetTextureFromYtd(texHash, fallbackYtd); 100 | if (texResult != null) 101 | { 102 | _logger.LogDebug($"💥 Fallback hit in {fallbackEntry.Path}"); 103 | return texResult; 104 | } 105 | } 106 | catch { } 107 | } 108 | } 109 | 110 | return texResult; 111 | } 112 | 113 | void CollectTextures(DrawableBase drawable) 114 | { 115 | if (drawable == null) return; 116 | var embedded = drawable.ShaderGroup?.TextureDictionary?.Textures?.data_items; 117 | if (embedded != null) 118 | { 119 | foreach (var tex in embedded) 120 | textures.Add(tex); 121 | } 122 | 123 | if (drawable.Owner is YptFile ypt && ypt.PtfxList?.TextureDictionary?.Textures?.data_items != null) 124 | { 125 | foreach (var tex in ypt.PtfxList.TextureDictionary.Textures.data_items) 126 | textures.Add(tex); 127 | return; 128 | } 129 | 130 | var shaders = drawable.ShaderGroup?.Shaders?.data_items; 131 | if (shaders == null) return; 132 | 133 | uint archHash = 0; 134 | if (drawable is Drawable dwbl) 135 | { 136 | var name = dwbl.Name?.ToLowerInvariant()?.Replace(".#dr", "")?.Replace(".#dd", ""); 137 | if (!string.IsNullOrEmpty(name)) archHash = JenkHash.GenHash(name); 138 | } 139 | else if (drawable is FragDrawable fdbl && fdbl.Owner is YftFile yftFile && yftFile.RpfFileEntry != null) 140 | { 141 | archHash = (uint)yftFile.RpfFileEntry.ShortNameHash; 142 | } 143 | 144 | var arch = _gameFileCache.GetArchetype(archHash); 145 | if (arch == null) 146 | { 147 | _logger.LogWarning($"⚠️ No archetype found for hash {archHash:X8} (name: '{(drawable is Drawable d ? d.Name : "unknown")}')"); 148 | } 149 | 150 | var txdHash = (arch != null) ? arch.TextureDict.Hash : archHash; 151 | 152 | foreach (var s in shaders) 153 | { 154 | if (s?.ParametersList?.Parameters == null) continue; 155 | foreach (var p in s.ParametersList.Parameters) 156 | { 157 | if (p?.Data is Texture tex) 158 | { 159 | textures.Add(tex); 160 | } 161 | else if (p?.Data is TextureBase baseTex) 162 | { 163 | var texhash = baseTex.NameHash; 164 | var texResult = TryGetTexture(texhash, txdHash); 165 | 166 | if (texResult == null && !string.IsNullOrEmpty(baseTex.Name)) 167 | { 168 | missing.Add(baseTex.Name); 169 | _logger.LogWarning($"Missing texture: {baseTex.Name}"); 170 | } 171 | else if (texResult != null) 172 | { 173 | textures.Add(texResult); 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | switch (ext) 181 | { 182 | case ".ydr": 183 | var ydr = RpfFile.GetFile(entry, fileBytes); 184 | if (ydr?.Drawable != null) 185 | CollectTextures(ydr.Drawable); 186 | break; 187 | 188 | case ".yft": 189 | _logger.LogDebug("▶ Begin YFT processing"); 190 | var yft = RpfFile.GetFile(entry, fileBytes); 191 | var f = yft?.Fragment; 192 | if (f != null) 193 | { 194 | CollectTextures(f.Drawable); 195 | _logger.LogDebug("▶ After Drawable"); 196 | CollectTextures(f.DrawableCloth); 197 | _logger.LogDebug("▶ After DrawableCloth"); 198 | 199 | if (f.DrawableArray?.data_items != null) 200 | { 201 | foreach (var d in f.DrawableArray.data_items) 202 | CollectTextures(d); 203 | } 204 | 205 | if (f.Cloths?.data_items != null) 206 | { 207 | foreach (var c in f.Cloths.data_items) 208 | CollectTextures(c.Drawable); 209 | } 210 | 211 | var children = f.PhysicsLODGroup?.PhysicsLOD1?.Children?.data_items; 212 | if (children != null) 213 | { 214 | foreach (var child in children) 215 | { 216 | CollectTextures(child.Drawable1); 217 | CollectTextures(child.Drawable2); 218 | } 219 | } 220 | } 221 | break; 222 | } 223 | 224 | _logger.LogInformation($"Total textures to save: {textures.Count}"); 225 | SaveTextures(textures, outputFolder); 226 | } 227 | 228 | private void SaveTextures(HashSet textures, string folder) 229 | { 230 | foreach (var tex in textures) 231 | { 232 | try 233 | { 234 | byte[] dds = DDSIO.GetDDSFile(tex); 235 | string fpath = Path.Combine(folder, tex.Name + ".dds"); 236 | File.WriteAllBytes(fpath, dds); 237 | } 238 | catch (Exception ex) 239 | { 240 | _logger.LogError($"Failed to save texture {tex.Name}: {ex.Message}"); 241 | } 242 | } 243 | } 244 | } --------------------------------------------------------------------------------