From 60861ff90c41e532c6c6280ca35982489d4bf436 Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Mon, 28 Sep 2026 11:56:41 -0700 Subject: [PATCH] RifleSuppressor v1.0.0 split from RifleScope --- .gitignore | 4 + Install.ps1 | 130 ++++++++++++ README.md | 78 +++++++ RifleSuppressor.cs | 447 +++++++++++++++++++++++++++++++++++++++++ RifleSuppressor.csproj | 37 ++++ build.ps1 | 159 +++++++++++++++ manifest.json | 11 + 7 files changed, 866 insertions(+) create mode 100644 .gitignore create mode 100644 Install.ps1 create mode 100644 README.md create mode 100644 RifleSuppressor.cs create mode 100644 RifleSuppressor.csproj create mode 100644 build.ps1 create mode 100644 manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ce79dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +dist/ +*.zip diff --git a/Install.ps1 b/Install.ps1 new file mode 100644 index 0000000..7a36a23 --- /dev/null +++ b/Install.ps1 @@ -0,0 +1,130 @@ +[CmdletBinding()] +param( + [string]$GameDir +) + +$ErrorActionPreference = 'Stop' + +function Write-Step { + param([string]$Text) + Write-Host '' + Write-Host $Text -ForegroundColor Cyan +} + +function Find-GameDir { + $steam = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath + if (-not $steam) { return $null } + $libs = @($steam) + $vdf = Join-Path $steam 'steamapps\libraryfolders.vdf' + if (Test-Path $vdf) { + Select-String -Path $vdf -Pattern '"path"\s+"(.+?)"' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $libs += $_.Groups[1].Value.Replace('\\','\') } + } + foreach ($lib in $libs) { + $candidate = Join-Path $lib 'steamapps\common\Sons Of The Forest' + if (Test-Path (Join-Path $candidate 'SonsOfTheForest.exe')) { return $candidate } + } + return $null +} + +function Get-ReleaseZip { + param([string]$Repo, [string]$Pattern, [string]$OutFile) + $release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" -Headers @{ 'User-Agent' = 'ps' } + $asset = $release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 + if (-not $asset) { throw "Could not find a download matching $Pattern in the latest $Repo release" } + Write-Host " version $($release.tag_name)" + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $OutFile -UseBasicParsing + return $asset.name +} + +try { + Write-Host '' + Write-Host '====================================' -ForegroundColor Green + Write-Host ' Rifle Suppressor installer' -ForegroundColor Green + Write-Host '====================================' -ForegroundColor Green + + if (Get-Process -Name 'SonsOfTheForest' -ErrorAction SilentlyContinue) { + throw 'Sons of the Forest is running. Close the game and run the installer again.' + } + + Write-Step 'Looking for Sons of the Forest' + if (-not $GameDir) { $GameDir = Find-GameDir } + if (-not $GameDir -or -not (Test-Path (Join-Path $GameDir 'SonsOfTheForest.exe'))) { + Write-Host ' Could not find the game automatically.' -ForegroundColor Yellow + Write-Host ' In Steam, right-click Sons Of The Forest, then Manage, then Browse local files.' + Write-Host ' Copy the folder path from the address bar and paste it below.' + $GameDir = (Read-Host ' Game folder').Trim('"') + if (-not (Test-Path (Join-Path $GameDir 'SonsOfTheForest.exe'))) { + throw "SonsOfTheForest.exe was not found in $GameDir" + } + } + Write-Host " found: $GameDir" -ForegroundColor Green + + $needsFirstLaunch = $false + + Write-Step 'Checking for RedLoader' + if (Test-Path (Join-Path $GameDir '_RedLoader\net6\SonsSdk.dll')) { + Write-Host ' already installed' -ForegroundColor Green + } + else { + Write-Host ' not installed, downloading it now' + $rlZip = Join-Path $env:TEMP 'RedLoader.zip' + Get-ReleaseZip -Repo 'ToniMacaroni/RedLoader' -Pattern '^RedLoader.*\.zip$' -OutFile $rlZip | Out-Null + + $rlStage = Join-Path $env:TEMP 'redloader-stage' + if (Test-Path $rlStage) { Remove-Item $rlStage -Recurse -Force } + Expand-Archive -Path $rlZip -DestinationPath $rlStage -Force + Copy-Item -Path (Join-Path $rlStage '*') -Destination $GameDir -Recurse -Force + Remove-Item $rlZip -Force -ErrorAction SilentlyContinue + Remove-Item $rlStage -Recurse -Force -ErrorAction SilentlyContinue + Write-Host ' installed' -ForegroundColor Green + $needsFirstLaunch = $true + } + + Write-Step 'Downloading RifleSuppressor' + $modZip = Join-Path $env:TEMP 'RifleSuppressor.zip' + Get-ReleaseZip -Repo 'JustinOros/sotf-riflesuppressor' -Pattern '^RifleSuppressor\.zip$' -OutFile $modZip | Out-Null + + Write-Step 'Installing RifleSuppressor' + $modsDir = Join-Path $GameDir 'Mods' + New-Item -ItemType Directory -Force -Path $modsDir | Out-Null + Remove-Item (Join-Path $modsDir 'RifleSuppressor.dll') -Force -ErrorAction SilentlyContinue + Remove-Item (Join-Path $modsDir 'RifleSuppressor') -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -Path $modZip -DestinationPath $modsDir -Force + Remove-Item $modZip -Force -ErrorAction SilentlyContinue + + if (-not (Test-Path (Join-Path $modsDir 'RifleSuppressor.dll'))) { + throw 'Something went wrong, RifleSuppressor.dll is not in the Mods folder' + } + if (-not (Test-Path (Join-Path $modsDir 'RifleSuppressor\manifest.json'))) { + throw 'Something went wrong, manifest.json is not in the Mods\RifleSuppressor folder' + } + Write-Host ' installed' -ForegroundColor Green + + Write-Host '' + Write-Host '====================================' -ForegroundColor Green + Write-Host ' Done' -ForegroundColor Green + Write-Host '====================================' -ForegroundColor Green + Write-Host '' + + if ($needsFirstLaunch) { + Write-Host 'RedLoader was installed for the first time, so the next time you start' + Write-Host 'the game it will take a few extra minutes to get ready. That is normal.' + Write-Host '' + } + + Write-Host 'Start Sons of the Forest, then:' + Write-Host ' 1. Check that MODS appears on the main menu and lists RifleSuppressor' + Write-Host ' 2. Load into a game, equip the rifle and fire' + Write-Host '' +} +catch { + Write-Host '' + Write-Host "Install failed: $($_.Exception.Message)" -ForegroundColor Red + if ($_.Exception.Message -match 'denied') { + Write-Host 'Try again from PowerShell opened with Run as administrator.' -ForegroundColor Yellow + } + Write-Host '' +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc48147 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# Rifle Suppressor + +Puts the pistol suppressor on the rifle in Sons of the Forest. Rifle shots use +the suppressed pistol shot sound. You do not need a suppressor in your inventory. + +This used to be part of [RifleScope](https://github.com/JustinOros/sotf-riflescope) +and is now its own mod, so you can use either one or both. + +## Multiplayer + +Client side only. Nothing gets installed on a dedicated server, and other players +do not need the mod. + +## Installation + +### Easiest + +Use the [Sons of the Forest mod loader](https://justinoros.github.io/sotf-modloader/) +in Chrome or Edge. + +### Easy install + +Close the game, open PowerShell and paste: + +```powershell +irm https://raw.githubusercontent.com/JustinOros/sotf-riflesuppressor/main/Install.ps1 | iex +``` + +It finds your game, installs RedLoader if needed, and installs the latest +RifleSuppressor. Run it again any time to update. + +### Manual install + +1. Install [RedLoader](https://github.com/ToniMacaroni/RedLoader/releases/latest) + into your Sons of the Forest folder and launch the game once +2. Download `RifleSuppressor.zip` from the + [latest release](https://github.com/JustinOros/sotf-riflesuppressor/releases/latest) +3. Extract it into the `Mods` folder inside your game folder +4. You should end up with: + +``` +Mods\RifleSuppressor.dll +Mods\RifleSuppressor\manifest.json +``` + +## Usage + +The suppressor is on as soon as the mod is installed. Equip the rifle and fire. + +Press F1 to open the console, then use these commands: + +| Command | Action | +| --- | --- | +| `suppressor` | Show whether the rifle suppressor is on or off | +| `suppressor on` | Put the suppressor on the rifle | +| `suppressor off` | Take the suppressor off the rifle | + +The setting is saved to `UserData\RifleSuppressor.txt` in your game folder. + +If you used the suppressor from RifleScope 1.1.1 or older, update RifleScope to +1.2.0 or newer so the two mods do not both register the `suppressor` command. + +## Building from source + +Requires the .NET 8 SDK and RedLoader installed with its game assemblies +generated. + +```powershell +.\build.ps1 -Install +``` + +Use `-Package` to build `RifleSuppressor.zip` for a release. Pass `-GameDir "path"` +if the game is not found automatically. + +## Troubleshooting + +Check `_RedLoader\Latest.log` in your game folder. RifleSuppressor logs a line +when it loads. diff --git a/RifleSuppressor.cs b/RifleSuppressor.cs new file mode 100644 index 0000000..865275d --- /dev/null +++ b/RifleSuppressor.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using RedLoader; +using RedLoader.Utils; +using Sons.FieldOfView; +using Sons.Items.Core; +using Sons.Weapon; +using SonsSdk; +using SonsSdk.Attributes; +using TheForest.Utils; +using TMPro; +using UnityEngine; +using UnityEngine.UI; + +namespace RifleSuppressor; + +public class RifleSuppressor : SonsMod +{ + private const string SuppressorModName = "CompactPistolSuppressorMod"; + private const string SuppressedFireEvent = "event:/SotF Events/player sounds/Weapons/PistolTactical/PistolTacticalFire"; + private const string MuzzleName = "RifleSuppressorMuzzle"; + private const string RifleFireEvent = "event:/SotF Events/player sounds/Weapons/Rifle/rifle_fire"; + private const string UpgradeParameter = "upgrade"; + private const float SuppressedUpgradeValue = 0.2f; + + private static bool _enabled = true; + private static string _configPath; + private static string _originalFireEvent; + private static HarmonyLib.Harmony _audioHarmony; + private static bool _loggedParamError; + private static bool _loggedPistolParams; + private static bool _reissuing; + + private static RifleAnimatorController _rifle; + private static RifleAnimatorController _pendingRifle; + private static ScriptableObject _suppressorMod; + + public RifleSuppressor() + { + OnUpdateCallback = OnUpdate; + HarmonyPatchAll = true; + } + + protected override void OnSdkInitialized() + { + _configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "RifleSuppressor.txt"); + Load(); + RLog.Msg($"RifleSuppressor loaded. Suppressor is {(_enabled ? "on" : "off")}. Change it with: suppressor on or suppressor off"); + } + + private void OnUpdate() + { + try + { + if (_rifle && _rifle.gameObject.activeInHierarchy) + return; + + _rifle = null; + var pending = _pendingRifle; + _pendingRifle = null; + if (!pending || !pending.gameObject.activeInHierarchy) + return; + AdoptRifle(pending); + } + catch (Exception e) + { + RLog.Error($"RifleSuppressor error: {e.Message}"); + _rifle = null; + } + } + + internal static void OnWeaponTick(RangedWeaponController controller) + { + if (!controller || (_rifle && _rifle.Pointer == controller.Pointer)) + return; + var rifle = controller.TryCast(); + if (rifle && rifle.IsLocalPlayer()) + _pendingRifle = rifle; + } + + private static void AdoptRifle(RifleAnimatorController rifle) + { + _rifle = rifle; + _originalFireEvent ??= rifle._gunShotAudioEvent; + if (_enabled) + SetSuppressor(rifle, true); + } + + private static WeaponMod FindSuppressorMod() + { + if (_suppressorMod) + return _suppressorMod.TryCast(); + foreach (var mod in Resources.FindObjectsOfTypeAll()) + { + if (mod && mod.name == SuppressorModName) + { + _suppressorMod = mod; + return mod; + } + } + return null; + } + + private static Transform FindChild(Transform root, string name) + { + foreach (var t in root.GetComponentsInChildren(true)) + { + if (t.name == name) + return t; + } + return null; + } + + private static bool EnsureMuzzleSlot(RangedWeaponController rifle, WeaponMods mods) + { + var links = mods._modLocations; + for (var i = 0; i < links.Count; i++) + { + if (links[i].Slot == WeaponMod.Slot.Muzzle) + return true; + } + + var anchor = FindChild(rifle.transform, "ProjectileVisualTransform"); + if (!anchor) + return false; + + var muzzle = FindChild(anchor.parent, MuzzleName); + if (!muzzle) + { + var go = new GameObject(MuzzleName); + go.layer = anchor.gameObject.layer; + muzzle = go.transform; + muzzle.SetParent(anchor.parent, false); + muzzle.localPosition = anchor.localPosition; + muzzle.localRotation = Quaternion.identity; + muzzle.localScale = Vector3.one; + } + + var link = new WeaponMods.ModLink(); + link.Slot = WeaponMod.Slot.Muzzle; + link.Location = muzzle; + links.Add(link); + return true; + } + + private static Type FindGameType(string name) + { + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + foreach (var asm in assemblies) + { + try + { + var t = asm.GetType(name, false) ?? asm.GetType("Il2Cpp." + name, false); + if (t != null) + return t; + } + catch + { + } + } + + foreach (var asm in assemblies) + { + var asmName = asm.GetName().Name ?? string.Empty; + if (asm.IsDynamic || asmName.StartsWith("System", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Unity", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Il2Cppmscorlib", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) || + asmName.StartsWith("mscorlib", StringComparison.OrdinalIgnoreCase)) + continue; + + Type[] types; + try + { + types = asm.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + types = e.Types.Where(t => t != null).ToArray(); + } + catch + { + continue; + } + + foreach (var t in types) + { + if (t != null && t.Name == name) + return t; + } + } + return null; + } + + private static void InstallAudioPatches() + { + if (_audioHarmony != null) + return; + _audioHarmony = new HarmonyLib.Harmony("RifleSuppressor.Audio"); + + var handler = FindGameType("FMOD_AnimationEventHandler"); + var animPrefix = new HarmonyMethod(typeof(RifleSuppressor).GetMethod(nameof(AnimEventPrefix), BindingFlags.NonPublic | BindingFlags.Static)); + var count = 0; + if (handler != null) + { + foreach (var m in handler.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + var ps = m.GetParameters(); + if (m.Name != "playFMODEvent" || ps.Length == 0 || ps[0].ParameterType != typeof(string)) + continue; + _audioHarmony.Patch(m, prefix: animPrefix); + count++; + } + } + + var common = FindGameType("FMODCommon"); + var oneshotPrefix = new HarmonyMethod(typeof(RifleSuppressor).GetMethod(nameof(OneshotPrefix), BindingFlags.NonPublic | BindingFlags.Static)); + if (common != null) + { + foreach (var m in common.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + var ps = m.GetParameters(); + if (m.Name != "PlayOneshotInternal" || ps.Length < 4 || ps[0].ParameterType != typeof(string)) + continue; + _audioHarmony.Patch(m, prefix: oneshotPrefix); + count++; + } + } + + RLog.Msg($"Suppressor audio: patched {count} methods (handler={(handler != null)}, common={(common != null)})"); + } + + private static bool RifleSuppressedNow() => + _enabled && _rifle && _rifle.gameObject.activeInHierarchy; + + private static bool AnimEventPrefix(Component __instance, object[] __args) + { + try + { + if (!RifleSuppressedNow() || __args == null || __args.Length == 0 || __args[0] as string != RifleFireEvent) + return true; + if (!__instance || __instance.transform.root != _rifle.transform.root) + return true; + return false; + } + catch + { + return true; + } + } + + private static bool OneshotPrefix(MethodBase __originalMethod, object[] __args) + { + if (_reissuing) + return true; + + try + { + if (__args == null || __args.Length < 4 || __args[0] as string != SuppressedFireEvent) + return true; + + var existing = __args[3] as Il2CppReferenceArray; + + if (!RifleSuppressedNow()) + { + if (!_loggedPistolParams && existing != null && existing.Length > 0) + { + _loggedPistolParams = true; + RLog.Msg($"Suppressor audio: pistol params [{string.Join(", ", existing.Select(Describe))}]"); + } + return true; + } + + var oldCount = existing?.Length ?? 0; + for (var i = 0; i < oldCount; i++) + { + if (existing[i] != null && existing[i].ToString() == UpgradeParameter) + return true; + } + + var array = new Il2CppReferenceArray(oldCount + 2); + for (var i = 0; i < oldCount; i++) + array[i] = existing[i]; + array[oldCount] = new Il2CppSystem.Object(IL2CPP.ManagedStringToIl2Cpp(UpgradeParameter)); + array[oldCount + 1] = BoxFloat(SuppressedUpgradeValue); + + var args = (object[])__args.Clone(); + args[3] = array; + _reissuing = true; + try + { + __originalMethod.Invoke(null, args); + } + finally + { + _reissuing = false; + } + return false; + } + catch (Exception e) + { + if (!_loggedParamError) + { + _loggedParamError = true; + RLog.Error($"Suppressor audio: could not play suppressed shot: {(e.InnerException ?? e).Message}"); + } + return true; + } + } + + private static Il2CppSystem.Object BoxFloat(float value) + { + object single = new Il2CppSystem.Single(); + typeof(Il2CppSystem.Single).GetField("m_value", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)?.SetValue(single, value); + return ((Il2CppSystem.Single)single).BoxIl2CppObject(); + } + + private static string Describe(Il2CppSystem.Object o) + { + if (o == null) + return "null"; + try + { + return $"{o.GetIl2CppType().Name}:{o.ToString()}"; + } + catch + { + return "?"; + } + } + + private static void SetSuppressor(RangedWeaponController rifle, bool on) + { + var weapon = rifle._rangedWeapon; + var mods = weapon ? weapon._weaponMods : null; + if (!mods) + { + Say("Suppressor: rifle has no WeaponMods"); + return; + } + + var mod = FindSuppressorMod(); + if (!mod) + { + Say("Suppressor: pistol suppressor mod is not loaded"); + return; + } + + if (on) + { + InstallAudioPatches(); + if (!EnsureMuzzleSlot(rifle, mods)) + { + Say("Suppressor: could not find the rifle muzzle"); + return; + } + + var applied = mods.HasMod(mod.ModItemId) || mods.ApplyMod(mod); + rifle._gunShotAudioEvent = SuppressedFireEvent; + RLog.Msg($"Suppressor on: applied={applied} slot={mod.AttachesToSlot} required={mod.RequiredSlot} item={mod.ModItemId}"); + return; + } + + if (mods.HasMod(mod.ModItemId)) + mods.RemoveMod(mod); + if (_originalFireEvent != null) + rifle._gunShotAudioEvent = _originalFireEvent; + } + + [DebugCommand("suppressor")] + private static void SuppressorCommand(string args) + { + args = (args ?? string.Empty).Trim().ToLowerInvariant(); + + if (args != "on" && args != "off") + { + Say($"Rifle suppressor is {(_enabled ? "on" : "off")}. Usage: suppressor on or suppressor off"); + return; + } + + _enabled = args == "on"; + Save(); + if (_rifle) + SetSuppressor(_rifle, _enabled); + Say($"Rifle suppressor {(_enabled ? "on" : "off")}"); + } + + private static void Say(string text) + { + RLog.Msg(text); + try + { + SonsTools.ShowMessage(text, 5f); + } + catch + { + } + } + + private static void Load() + { + try + { + if (!File.Exists(_configPath)) + return; + _enabled = File.ReadAllText(_configPath).Trim() != "0"; + } + catch (Exception e) + { + RLog.Error($"RifleSuppressor could not read config: {e.Message}"); + } + } + + private static void Save() + { + try + { + File.WriteAllText(_configPath, _enabled ? "1" : "0"); + } + catch (Exception e) + { + RLog.Error($"RifleSuppressor could not write config: {e.Message}"); + } + } +} + +[HarmonyPatch(typeof(RangedWeaponController), nameof(RangedWeaponController.CheckFireInput))] +internal static class CheckFireInputPatch +{ + private static void Postfix(RangedWeaponController __instance) + { + try + { + global::RifleSuppressor.RifleSuppressor.OnWeaponTick(__instance); + } + catch + { + } + } +} diff --git a/RifleSuppressor.csproj b/RifleSuppressor.csproj new file mode 100644 index 0000000..337c0d4 --- /dev/null +++ b/RifleSuppressor.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + RifleSuppressor + RifleSuppressor + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..ae4c5b9 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,159 @@ +[CmdletBinding()] +param( + [string]$GameDir, + [switch]$Install, + [switch]$Package +) + +$ErrorActionPreference = 'Stop' + +function Test-Dotnet { + $cmd = Get-Command dotnet -ErrorAction SilentlyContinue + if (-not $cmd) { return $false } + $sdks = & dotnet --list-sdks 2>$null + return ($LASTEXITCODE -eq 0 -and $sdks) +} + +function Update-Path { + $env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path','User') + $dotnetRoot = Join-Path $env:ProgramFiles 'dotnet' + if ((Test-Path $dotnetRoot) -and ($env:Path -notlike "*$dotnetRoot*")) { + $env:Path += ";$dotnetRoot" + } +} + +function Install-Dotnet { + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host 'Installing the .NET 8 SDK with winget' -ForegroundColor Cyan + winget install --id Microsoft.DotNet.SDK.8 --accept-source-agreements --accept-package-agreements --silent + Update-Path + if (Test-Dotnet) { return } + Write-Host ' winget install did not make dotnet available, falling back' -ForegroundColor Yellow + } + + Write-Host 'Downloading the official dotnet-install script' -ForegroundColor Cyan + $script = Join-Path $env:TEMP 'dotnet-install.ps1' + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $script -UseBasicParsing + + Write-Host 'Installing the .NET 8 SDK to your user profile' -ForegroundColor Cyan + & $script -Channel 8.0 -InstallDir "$env:LOCALAPPDATA\Microsoft\dotnet" + $userDotnet = "$env:LOCALAPPDATA\Microsoft\dotnet" + $env:Path = "$userDotnet;$env:Path" + [Environment]::SetEnvironmentVariable( + 'Path', + "$userDotnet;" + [Environment]::GetEnvironmentVariable('Path','User'), + 'User') + Remove-Item $script -Force -ErrorAction SilentlyContinue +} + +function Install-RedLoader { + param([string]$Target) + + Write-Host 'Looking up the latest RedLoader release' -ForegroundColor Cyan + $release = Invoke-RestMethod 'https://api.github.com/repos/ToniMacaroni/RedLoader/releases/latest' -Headers @{ 'User-Agent' = 'ps' } + $asset = $release.assets | Where-Object { $_.name -match '^RedLoader.*\.zip$' } | Select-Object -First 1 + if (-not $asset) { throw 'Could not find RedLoader.zip in the latest release' } + Write-Host " $($release.tag_name) / $($asset.name)" + + $zip = Join-Path $env:TEMP $asset.name + Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $zip -UseBasicParsing + + $stage = Join-Path $env:TEMP 'redloader-extract' + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + Expand-Archive -Path $zip -DestinationPath $stage -Force + Copy-Item -Path (Join-Path $stage '*') -Destination $Target -Recurse -Force + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Remove-Item $stage -Recurse -Force -ErrorAction SilentlyContinue + + Write-Host ' RedLoader installed' -ForegroundColor Green +} + +function Find-GameDir { + if ($env:SOTF_PATH) { return $env:SOTF_PATH } + $steam = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath + if (-not $steam) { return $null } + $libs = @($steam) + $vdf = Join-Path $steam 'steamapps\libraryfolders.vdf' + if (Test-Path $vdf) { + Select-String -Path $vdf -Pattern '"path"\s+"(.+?)"' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $libs += $_.Groups[1].Value.Replace('\\','\') } + } + foreach ($lib in $libs) { + $candidate = Join-Path $lib 'steamapps\common\Sons Of The Forest' + if (Test-Path (Join-Path $candidate 'SonsOfTheForest.exe')) { return $candidate } + } + return $null +} + +if (-not (Test-Dotnet)) { + Write-Host '' + Write-Host 'The .NET 8 SDK is required to build this mod and was not found.' -ForegroundColor Yellow + $answer = Read-Host 'Download and install it now? (y/N)' + if ($answer -ne 'y') { + throw 'Cannot build without the .NET SDK. Get it at https://dotnet.microsoft.com/download' + } + Install-Dotnet + if (-not (Test-Dotnet)) { + throw 'The SDK was installed but dotnet is still not on PATH. Reopen PowerShell and rerun this script.' + } +} +Write-Host ("dotnet SDK: " + (dotnet --version)) -ForegroundColor Green + +if (-not $GameDir) { $GameDir = Find-GameDir } +if (-not $GameDir) { + throw 'Could not find Sons Of The Forest. Pass -GameDir "path" or set SOTF_PATH.' +} +Write-Host "Game directory: $GameDir" -ForegroundColor Cyan + +if (-not (Test-Path (Join-Path $GameDir '_RedLoader\net6\SonsSdk.dll'))) { + Write-Host '' + Write-Host 'RedLoader is not installed in your game folder.' -ForegroundColor Yellow + $answer = Read-Host 'Download and install the latest RedLoader release now? (y/N)' + if ($answer -ne 'y') { + throw 'RedLoader is required. Get it at https://github.com/ToniMacaroni/RedLoader/releases' + } + Install-RedLoader -Target $GameDir +} + +if (-not (Test-Path (Join-Path $GameDir '_RedLoader\Game\Sons.dll'))) { + Write-Host '' + Write-Host 'RedLoader is installed but has not generated its game assemblies yet.' -ForegroundColor Yellow + Write-Host 'Launch Sons of the Forest once and wait for the main menu. The first' + Write-Host 'launch takes a few minutes while it processes the game files.' + Write-Host '' + Write-Host 'Then quit the game and run this script again.' + return +} +Write-Host 'RedLoader ready' -ForegroundColor Green + +dotnet build -c Release --nologo -p:GameDir="$GameDir" +if ($LASTEXITCODE -ne 0) { throw 'Build failed' } + +$dll = Join-Path $PSScriptRoot 'bin\Release\net6\RifleSuppressor.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'RifleSuppressor' + New-Item -ItemType Directory -Force -Path $modDataDir | Out-Null + Copy-Item $dll -Destination $modsDir -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination $modDataDir -Force + Write-Host "Installed to $modsDir" -ForegroundColor Green +} + +if ($Package) { + $stage = Join-Path $PSScriptRoot 'dist' + $zip = Join-Path $PSScriptRoot 'RifleSuppressor.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'RifleSuppressor') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'RifleSuppressor') -Force + + if (Test-Path $zip) { Remove-Item $zip -Force } + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip + Remove-Item $stage -Recurse -Force + + Write-Host "Packaged $zip" -ForegroundColor Green +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6352bdf --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "RifleSuppressor", + "name": "RifleSuppressor", + "author": "JustinOros", + "version": "1.0.0", + "description": "Puts the pistol suppressor on the rifle for quiet, suppressed shots", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +} \ No newline at end of file