#Requires -Version 5.1 <# .SYNOPSIS Tower Networking Inc - WPF Mod Manager v3.5.2 .DESCRIPTION A Windows Presentation Foundation GUI for managing TNI mods. Downloads mods from GitHub releases, manages local mods, and configures parameters. .NOTES Author: CJFWeatherhead Version: 3.5.4 Requires: PowerShell 5.1+, .NET Framework 4.5+ #> Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Windows.Forms # Configuration $script:AppDataPath = [Environment]::GetFolderPath('ApplicationData') $script:GameDataPath = Join-Path $script:AppDataPath "Godot\app_userdata\Tower Networking Inc" $script:ModsDirectory = Join-Path $script:GameDataPath "Mods" $script:DisabledModsDirectory = Join-Path $script:GameDataPath "Mods_Disabled" $script:SettingsPath = Join-Path $script:GameDataPath "settings.json" $script:ModCachePath = Join-Path $script:GameDataPath "mod_cache.json" $script:ConfigFileName = "entry.lua" $script:LuaJitPath = Join-Path $script:ModsDirectory "luajit.elf" # GitHub Configuration $script:GitHubRepo = "CJFWeatherhead/TNI-Mods" $script:GitHubApiBase = "https://api.github.com/repos/$script:GitHubRepo" $script:LuaJitReleaseTag = "continuous-gnu-beta" $script:LuaJitZipUrl = "https://github.com/$script:GitHubRepo/releases/download/$script:LuaJitReleaseTag/luajit.zip" # Startup logging Write-Host "======================================" -ForegroundColor Cyan Write-Host "TNI Mod Manager v3.0 - Starting up..." -ForegroundColor Cyan Write-Host "======================================" -ForegroundColor Cyan Write-Host "" Write-Host "Configuration:" -ForegroundColor Yellow Write-Host " Game Data Path: " -NoNewline -ForegroundColor Gray Write-Host $script:GameDataPath -ForegroundColor White Write-Host " Mods Directory: " -NoNewline -ForegroundColor Gray Write-Host $script:ModsDirectory -ForegroundColor White Write-Host " GitHub Repository: " -NoNewline -ForegroundColor Gray Write-Host $script:GitHubRepo -ForegroundColor White Write-Host "" # Verify paths exist if (Test-Path $script:ModsDirectory) { Write-Host "[OK] Mods directory found" -ForegroundColor Green } else { Write-Host "[WARN] Mods directory not found - will be created when needed" -ForegroundColor Yellow } Write-Host "" # Global state $script:Config = $null $script:InstalledMods = @() $script:AllMods = @() $script:GitHubReleases = @{} $script:CurrentMod = $null $script:Settings = $null $script:CmdAliases = @{} $script:ModCache = @{} $script:IsDownloading = $false # Mod source types $script:ModSourceType = @{ Downloaded = "Downloaded" Manual = "Manual" Available = "Available" } #region LuaJIT Management function Test-LuaJitInstalled { <# .SYNOPSIS Checks if luajit.elf is installed in the mods directory #> return (Test-Path $script:LuaJitPath) } function Install-LuaJit { <# .SYNOPSIS Downloads and installs luajit.elf from GitHub releases #> param( [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) try { Write-Host "Downloading LuaJIT..." -ForegroundColor Cyan if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Downloading LuaJIT..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $false $ProgressBar.Value = 0 $ProgressBar.Visibility = "Visible" }) } $tempZipPath = Join-Path $env:TEMP "luajit.zip" # Ensure mods directory exists if (-not (Test-Path $script:ModsDirectory)) { New-Item -Path $script:ModsDirectory -ItemType Directory -Force | Out-Null } # Download with progress using WebClient $webClient = New-Object System.Net.WebClient $webClient.Headers.Add("User-Agent", "TNI-ModManager/3.0") # Get file size $totalBytes = 0 $downloadedBytes = 0 try { $request = [System.Net.WebRequest]::Create($script:LuaJitZipUrl) $request.Method = "HEAD" $request.UserAgent = "TNI-ModManager/3.0" $response = $request.GetResponse() $totalBytes = $response.ContentLength $response.Close() } catch { Write-Host " Could not determine file size, continuing anyway..." -ForegroundColor Yellow } # Download with progress tracking $stream = $webClient.OpenRead($script:LuaJitZipUrl) $fileStream = [System.IO.File]::Create($tempZipPath) $buffer = New-Object byte[] 8192 $lastProgress = 0 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $fileStream.Write($buffer, 0, $read) $downloadedBytes += $read if ($totalBytes -gt 0 -and $ProgressBar) { $progress = [int](($downloadedBytes / $totalBytes) * 100) if ($progress -ne $lastProgress) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Value = $progress }) $lastProgress = $progress } } # Allow UI to update [System.Windows.Forms.Application]::DoEvents() } $stream.Close() $fileStream.Close() $webClient.Dispose() if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Extracting LuaJIT..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $true }) } # Extract luajit.elf from zip Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [System.IO.Compression.ZipFile]::OpenRead($tempZipPath) $luajitEntry = $zip.Entries | Where-Object { $_.Name -eq "luajit.elf" } | Select-Object -First 1 if ($luajitEntry) { [System.IO.Compression.ZipFileExtensions]::ExtractToFile($luajitEntry, $script:LuaJitPath, $true) Write-Host " [OK] LuaJIT installed successfully" -ForegroundColor Green $success = $true } else { Write-Host " [ERROR] luajit.elf not found in archive" -ForegroundColor Red $success = $false } $zip.Dispose() # Clean up temp file Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = if ($success) { "LuaJIT installed successfully!" } else { "LuaJIT installation failed" } }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $success } catch { Write-Host "[ERROR] LuaJIT installation failed: $_" -ForegroundColor Red if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "LuaJIT installation failed: $_" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $false } } function Show-LuaJitPrompt { <# .SYNOPSIS Shows a prompt to the user about missing LuaJIT and offers to download it #> param( [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) $message = @" LuaJIT Runtime Missing The LuaJIT runtime (luajit.elf) is required to load Lua mods. It was not found in your mods directory. Would you like to download and install it now? Location: $script:LuaJitPath "@ $result = [System.Windows.MessageBox]::Show( $message, "LuaJIT Required", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question ) if ($result -eq "Yes") { $installSuccess = Install-LuaJit -ProgressBar $ProgressBar -StatusText $StatusText if ($installSuccess) { [System.Windows.MessageBox]::Show( "LuaJIT has been installed successfully!`n`nYour mods should now load correctly.", "Installation Complete", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) } else { [System.Windows.MessageBox]::Show( "Failed to install LuaJIT.`n`nPlease download luajit.zip manually from:`n$script:LuaJitZipUrl`n`nExtract luajit.elf to:`n$script:LuaJitPath", "Installation Failed", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error ) } return $installSuccess } else { $warning = @" Warning: Without LuaJIT, mods are unlikely to load correctly. You can install it later by downloading luajit.zip from: $script:LuaJitZipUrl Extract luajit.elf to: $script:LuaJitPath "@ [System.Windows.MessageBox]::Show( $warning, "LuaJIT Not Installed", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning ) return $false } } #endregion #region GitHub API Functions function Get-GitHubReleases { <# .SYNOPSIS Fetches all mod releases from GitHub #> try { Write-Host "Fetching releases from GitHub..." -ForegroundColor Cyan $uri = "$script:GitHubApiBase/releases" $headers = @{ "Accept" = "application/vnd.github.v3+json" "User-Agent" = "TNI-ModManager/3.0" } $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -TimeoutSec 30 $modReleases = @{} foreach ($release in $response) { # Parse release tag: format is -v if ($release.tag_name -match '^(.+)-v(\d+\.\d+\.\d+)$') { $modId = $Matches[1] $version = $Matches[2] # Find the zip asset $asset = $release.assets | Where-Object { $_.name -like "*.zip" } | Select-Object -First 1 if ($asset) { $releaseInfo = @{ ModId = $modId Version = $version TagName = $release.tag_name DownloadUrl = $asset.browser_download_url AssetName = $asset.name Size = $asset.size PublishedAt = $release.published_at ReleaseNotes = $release.body HtmlUrl = $release.html_url } # Keep only the latest version for each mod if (-not $modReleases.ContainsKey($modId) -or (Compare-SemanticVersion $version $modReleases[$modId].Version) -gt 0) { $modReleases[$modId] = $releaseInfo } } } } Write-Host " Found $($modReleases.Count) mod releases" -ForegroundColor Green return $modReleases } catch { Write-Host "[ERROR] Failed to fetch GitHub releases: $_" -ForegroundColor Red return @{} } } function Compare-SemanticVersion { <# .SYNOPSIS Compares two semantic versions. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal #> param( [string]$Version1, [string]$Version2 ) try { $v1Parts = $Version1 -split '\.' | ForEach-Object { [int]$_ } $v2Parts = $Version2 -split '\.' | ForEach-Object { [int]$_ } for ($i = 0; $i -lt 3; $i++) { $v1 = if ($i -lt $v1Parts.Count) { $v1Parts[$i] } else { 0 } $v2 = if ($i -lt $v2Parts.Count) { $v2Parts[$i] } else { 0 } if ($v1 -gt $v2) { return 1 } if ($v1 -lt $v2) { return -1 } } return 0 } catch { return 0 } } function Download-ModFromGitHub { <# .SYNOPSIS Downloads and installs a mod from GitHub releases #> param( [hashtable]$ReleaseInfo, [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) $script:IsDownloading = $true try { $modId = $ReleaseInfo.ModId $downloadUrl = $ReleaseInfo.DownloadUrl $tempZipPath = Join-Path $env:TEMP "$modId-$($ReleaseInfo.Version).zip" $modTargetPath = Join-Path $script:ModsDirectory $modId Write-Host "Downloading $modId v$($ReleaseInfo.Version)..." -ForegroundColor Cyan if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Downloading $modId..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $false $ProgressBar.Value = 0 $ProgressBar.Visibility = "Visible" }) } # Download with progress using WebClient $webClient = New-Object System.Net.WebClient $webClient.Headers.Add("User-Agent", "TNI-ModManager/3.0") # Simple synchronous download with progress callback $totalBytes = 0 $downloadedBytes = 0 # First, get the file size try { $request = [System.Net.WebRequest]::Create($downloadUrl) $request.Method = "HEAD" $request.UserAgent = "TNI-ModManager/3.0" $response = $request.GetResponse() $totalBytes = $response.ContentLength $response.Close() } catch { Write-Host " Could not determine file size, continuing anyway..." -ForegroundColor Yellow } # Download with progress tracking $stream = $webClient.OpenRead($downloadUrl) $fileStream = [System.IO.File]::Create($tempZipPath) $buffer = New-Object byte[] 8192 $lastProgress = 0 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $fileStream.Write($buffer, 0, $read) $downloadedBytes += $read if ($totalBytes -gt 0 -and $ProgressBar) { $progress = [int](($downloadedBytes / $totalBytes) * 100) if ($progress -ne $lastProgress) { $lastProgress = $progress $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Value = $progress }) } } # Allow UI to update [System.Windows.Forms.Application]::DoEvents() } $stream.Close() $fileStream.Close() $webClient.Dispose() if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Extracting $modId..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $true }) } # Remove existing mod folder if exists if (Test-Path $modTargetPath) { Remove-Item $modTargetPath -Recurse -Force } # Create mods directory if needed if (-not (Test-Path $script:ModsDirectory)) { New-Item -Path $script:ModsDirectory -ItemType Directory -Force | Out-Null } # Extract zip Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($tempZipPath, $modTargetPath) # Clean up temp file Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue # Update mod cache to track this as a downloaded mod $script:ModCache[$modId] = @{ Source = $script:ModSourceType.Downloaded Version = $ReleaseInfo.Version InstalledAt = (Get-Date).ToString("o") } Save-ModCache if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "$modId installed successfully!" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } Write-Host " [OK] Installed $modId v$($ReleaseInfo.Version)" -ForegroundColor Green return $true } catch { Write-Host "[ERROR] Download failed: $_" -ForegroundColor Red if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Download failed: $_" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $false } finally { $script:IsDownloading = $false } } function Remove-DownloadedMod { <# .SYNOPSIS Completely removes a downloaded mod from the filesystem #> param([string]$ModId) try { $modPath = Join-Path $script:ModsDirectory $ModId if (Test-Path $modPath) { Remove-Item $modPath -Recurse -Force Write-Host " [OK] Removed downloaded mod: $ModId" -ForegroundColor Green } # Remove from cache if ($script:ModCache.ContainsKey($ModId)) { $script:ModCache.Remove($ModId) Save-ModCache } return $true } catch { Write-Host "[ERROR] Failed to remove mod: $_" -ForegroundColor Red return $false } } #endregion #region Cache Management function Load-ModCache { <# .SYNOPSIS Loads the mod cache that tracks downloaded vs manual mods #> if (Test-Path $script:ModCachePath) { try { $content = Get-Content $script:ModCachePath -Raw -Encoding UTF8 $jsonObj = $content | ConvertFrom-Json # Convert PSObject to hashtable $script:ModCache = @{} foreach ($prop in $jsonObj.PSObject.Properties) { $script:ModCache[$prop.Name] = @{ Source = $prop.Value.Source Version = $prop.Value.Version InstalledAt = $prop.Value.InstalledAt } } Write-Host " Loaded mod cache with $($script:ModCache.Count) entries" -ForegroundColor Gray } catch { Write-Host "[WARN] Failed to load mod cache: $_" -ForegroundColor Yellow $script:ModCache = @{} } } else { $script:ModCache = @{} } } function Save-ModCache { <# .SYNOPSIS Saves the mod cache to disk #> try { $cacheDir = Split-Path $script:ModCachePath -Parent if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null } $json = $script:ModCache | ConvertTo-Json -Depth 10 $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($script:ModCachePath, $json, $utf8NoBom) } catch { Write-Host "[WARN] Failed to save mod cache: $_" -ForegroundColor Yellow } } #endregion #region Helper Functions function Convert-MarkdownToTextBlock { <# .SYNOPSIS Converts basic markdown (bold and links) to WPF TextBlock with Inlines #> param( [string]$Text, [System.Windows.Controls.TextBlock]$TextBlock ) if ([string]::IsNullOrWhiteSpace($Text)) { return } $TextBlock.Inlines.Clear() $TextBlock.TextWrapping = "Wrap" # Split by markdown patterns $pattern = '(\*\*.*?\*\*|\[.*?\]\(.*?\))' $parts = [regex]::Split($Text, $pattern) foreach ($part in $parts) { if ([string]::IsNullOrEmpty($part)) { continue } # Handle bold: **text** if ($part -match '^\*\*(.*?)\*\*$') { $run = New-Object System.Windows.Documents.Run $run.Text = $Matches[1] $run.FontWeight = "Bold" $TextBlock.Inlines.Add($run) } # Handle links: [text](url) elseif ($part -match '^\[(.*?)\]\((.*?)\)$') { $linkText = $Matches[1] $linkUrl = $Matches[2] $hyperlink = New-Object System.Windows.Documents.Hyperlink $hyperlink.NavigateUri = $linkUrl $run = New-Object System.Windows.Documents.Run $run.Text = $linkText $hyperlink.Inlines.Add($run) $hyperlink.Foreground = "#FF0078D4" $hyperlink.Add_RequestNavigate({ param($sender, $e) Start-Process $e.Uri.AbsoluteUri $e.Handled = $true }) $TextBlock.Inlines.Add($hyperlink) } # Plain text else { $run = New-Object System.Windows.Documents.Run $run.Text = $part $TextBlock.Inlines.Add($run) } } } function Get-ModStatusColor { param( [string]$Status, [bool]$Enabled ) $colors = @{ 'Active' = @{ Enabled = '#FF4CAF50'; Disabled = '#FF2E7D32' } 'Maintenance' = @{ Enabled = '#FFFFD54F'; Disabled = '#FFF9A825' } 'Discontinued' = @{ Enabled = '#FFFF9800'; Disabled = '#FFE65100' } 'Unsupported' = @{ Enabled = '#FFF44336'; Disabled = '#FFC62828' } 'Defunct' = @{ Enabled = '#FFF44336'; Disabled = '#FFC62828' } } if ($colors.ContainsKey($Status)) { if ($Enabled) { return $colors[$Status]['Enabled'] } else { return $colors[$Status]['Disabled'] } } if ($Enabled) { return '#FF808080' } else { return '#FF505050' } } function Get-ModSourceColor { param([string]$Source) switch ($Source) { "Downloaded" { return "#FF0078D4" } # Blue "Manual" { return "#FF9C27B0" } # Purple "Available" { return "#FF607D8B" } # Gray default { return "#FF808080" } } } function Get-ModSourceIcon { param([string]$Source) switch ($Source) { "Downloaded" { return [char]0x2601 } # Cloud "Manual" { return [char]0x1F4C1 } # Folder "Available" { return [char]0x2B07 } # Down arrow default { return "?" } } } #endregion #region XAML UI Definition $xaml = @"