From a143b2a9dc9c2480b332a7c97eea0b620e5371bd Mon Sep 17 00:00:00 2001 From: Justin Oros Date: Tue, 15 Sep 2026 10:43:47 -0700 Subject: [PATCH] Initial commit --- .gitignore | 4 + FullAuto.cs | 253 ++++++++++++++++++++++++++++++++++++++++++++++++ FullAuto.csproj | 37 +++++++ README.md | 86 ++++++++++++++++ build.ps1 | 159 ++++++++++++++++++++++++++++++ manifest.json | 11 +++ 6 files changed, 550 insertions(+) create mode 100644 .gitignore create mode 100644 FullAuto.cs create mode 100644 FullAuto.csproj create mode 100644 README.md 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/FullAuto.cs b/FullAuto.cs new file mode 100644 index 0000000..4c834f3 --- /dev/null +++ b/FullAuto.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using HarmonyLib; +using RedLoader; +using RedLoader.Utils; +using Sons.Weapon; +using SonsSdk; +using SonsSdk.Attributes; +using TheForest.Utils; +using UnityEngine; + +namespace FullAuto; + +public class FullAuto : SonsMod +{ + internal const float MinRpm = 60f; + internal const float MaxRpm = 3000f; + + internal static bool Enabled = true; + internal static float Rpm = 900f; + internal static bool Verbose; + + private static string _configPath; + + public FullAuto() + { + HarmonyPatchAll = true; + OnUpdateCallback = OnUpdate; + } + + protected override void OnSdkInitialized() + { + _configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "FullAuto.txt"); + Load(); + RLog.Msg($"FullAuto loaded. Enabled: {Enabled}, RPM: {Rpm}. F10 toggles, console: fullauto"); + } + + private void OnUpdate() + { + if (!GameState.IsPlayerControllable) + return; + + if (Input.GetKeyDown(KeyCode.F10)) + { + Enabled = !Enabled; + Save(); + Announce(); + } + } + + [DebugCommand("fullauto")] + private static void FullAutoCommand(string args) + { + var parts = (args ?? string.Empty).Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 0) + { + Announce(); + return; + } + + switch (parts[0].ToLowerInvariant()) + { + case "on": + Enabled = true; + break; + case "off": + Enabled = false; + break; + case "toggle": + Enabled = !Enabled; + break; + case "debug": + Verbose = !Verbose; + SonsTools.ShowMessage($"FullAuto debug: {Verbose}"); + RLog.Msg($"FullAuto debug: {Verbose}"); + return; + case "rpm": + if (parts.Length > 1 && float.TryParse(parts[1], out var value)) + { + Rpm = Mathf.Clamp(value, MinRpm, MaxRpm); + break; + } + Usage(); + return; + default: + Usage(); + return; + } + + Save(); + Announce(); + } + + private static void Usage() + { + var text = $"Usage: fullauto [on|off|toggle|debug] or fullauto rpm <{MinRpm}-{MaxRpm}>"; + SonsTools.ShowMessage(text, 5f); + RLog.Msg(text); + } + + internal static void Announce() + { + var text = $"FullAuto {(Enabled ? "ON" : "OFF")} ({Rpm} RPM)"; + SonsTools.ShowMessage(text); + RLog.Msg(text); + } + + private static void Load() + { + try + { + if (!File.Exists(_configPath)) + return; + + foreach (var line in File.ReadAllLines(_configPath)) + { + var kv = line.Split('=', 2); + if (kv.Length != 2) + continue; + + var key = kv[0].Trim().ToLowerInvariant(); + var val = kv[1].Trim(); + + if (key == "enabled" && bool.TryParse(val, out var enabled)) + Enabled = enabled; + else if (key == "rpm" && float.TryParse(val, out var rpm)) + Rpm = Mathf.Clamp(rpm, MinRpm, MaxRpm); + } + } + catch (Exception e) + { + RLog.Error($"FullAuto could not read config: {e.Message}"); + } + } + + private static void Save() + { + try + { + File.WriteAllText(_configPath, $"enabled={Enabled}\nrpm={Rpm}\n"); + } + catch (Exception e) + { + RLog.Error($"FullAuto could not write config: {e.Message}"); + } + } +} + +[HarmonyPatch(typeof(RangedWeaponController), nameof(RangedWeaponController.CheckFireInput))] +internal static class CheckFireInputPatch +{ + private const int MaxShotsPerFrame = 4; + + private static readonly HashSet Excluded = new() + { + "GrenadeWeaponController", + "MolotovWeaponController", + "TimeBombWeaponController", + "SmallRockWeaponController", + "RopeGunController" + }; + + private static readonly Dictionary Allowed = new(); + private static readonly HashSet Reported = new(); + private static float _nextShot; + private static bool _loggedHook; + + private static bool Prefix(RangedWeaponController __instance) + { + if (!_loggedHook) + { + _loggedHook = true; + RLog.Msg("FullAuto: CheckFireInput hook is firing"); + } + + if (!FullAuto.Enabled || !__instance.IsLocalPlayer() || !IsAllowed(__instance)) + return true; + + if (!Input.GetMouseButton(0) || !GameState.IsPlayerControllable) + return true; + + if (__instance._isReloading || __instance.IsReloading()) + return true; + + if (__instance._mustAimToFire && !__instance.IsAiming) + return true; + + var weapon = __instance.GetRangedWeapon(); + if (!weapon) + return true; + + var ammo = weapon.GetAmmo(); + if (ammo == null || ammo.GetRemainingAmmo() <= 0) + return true; + + var now = Time.time; + var interval = 60f / FullAuto.Rpm; + + if (_nextShot < now - interval) + _nextShot = now; + + var shots = 0; + while (_nextShot <= now && shots < MaxShotsPerFrame && ammo.GetRemainingAmmo() > 0) + { + Fire(__instance, ammo); + _nextShot += interval; + shots++; + } + + return false; + } + + private static void Fire(RangedWeaponController controller, RangedWeapon.Ammo ammo) + { + var before = ammo.GetRemainingAmmo(); + + controller._fireDelay = 0f; + controller._nextFireDelay = 0f; + controller.FireWeapon(); + + if (controller._playFireAudio == RangedWeaponController.FireAudio.OnAnimShootCallback) + { + controller.TriggerShotFiredAudio(); + if (controller.ShouldShowMuzzleFlash()) + controller.OnMuzzleFlash(); + } + + controller._attackState = RangedWeaponController.AttackState.Idle; + + var after = ammo.GetRemainingAmmo(); + var name = controller.GetIl2CppType().Name; + + if (Reported.Add(name)) + RLog.Msg($"FullAuto first shot with {name}: ammo {before} -> {after}, audio {controller._playFireAudio}"); + + if (FullAuto.Verbose) + RLog.Msg($"FullAuto shot {name}: ammo {before} -> {after}"); + } + + private static bool IsAllowed(RangedWeaponController controller) + { + var ptr = controller.Pointer; + if (Allowed.TryGetValue(ptr, out var allowed)) + return allowed; + + allowed = !Excluded.Contains(controller.GetIl2CppType().Name); + Allowed[ptr] = allowed; + return allowed; + } +} + diff --git a/FullAuto.csproj b/FullAuto.csproj new file mode 100644 index 0000000..b4e00da --- /dev/null +++ b/FullAuto.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + FullAuto + FullAuto + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..57c4187 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# FullAuto + +Hold the fire button to shoot any gun full-auto in Sons of the Forest. + +Works with the pistol, revolver, shotgun, rifle and other guns. The fire rate is +adjustable and your settings are saved between sessions. + +## Multiplayer + +Client side only. Nothing gets installed on a dedicated server, and other +players do not need the mod. + +## Installation + +### Step 1: Install RedLoader + +[RedLoader](https://github.com/ToniMacaroni/RedLoader/releases/latest) is the mod +loader for Sons of the Forest. The game cannot load any mod without it, so +install it first. You only have to do this once. + +1. Download `RedLoader.zip` from the + [latest RedLoader release](https://github.com/ToniMacaroni/RedLoader/releases/latest) +2. Extract it into your Sons of the Forest folder, the one containing + `SonsOfTheForest.exe`, usually + `C:\Program Files (x86)\Steam\steamapps\common\Sons Of The Forest` +3. Launch the game once and wait until you reach the main menu. The first launch + takes a few minutes while RedLoader processes the game files +4. Check that `MODS` appears on the main menu, then quit + +### Step 2: Install FullAuto + +1. Download `FullAuto.zip` from the + [latest release](https://github.com/JustinOros/sotf-fullauto/releases/latest) +2. Extract it into the `Mods` folder inside your game folder +3. You should end up with: + +``` +Mods\FullAuto.dll +Mods\FullAuto\manifest.json +``` + +## Usage + +Equip a gun and hold the fire button. + +| Control | Action | +| --- | --- | +| F8 | Toggle the mod on or off | +| F1 | Open the console | + +Console commands: + +| Command | Action | +| --- | --- | +| `fullauto` | Show current settings | +| `fullauto on` | Enable | +| `fullauto off` | Disable | +| `fullauto rpm 1200` | Set the fire rate, 60 to 6000 rounds per minute | +| `fullauto mode shoot` | Change the fire method, `shoot`, `fire` or `trigger` | + +The default is 900 rpm with mode `shoot`. If a gun does not fire full-auto, try +another mode. + +Settings are saved to `UserData\FullAuto.cfg` in your game folder. + +## Excluded weapons + +The bow, slingshot, rope gun and thrown items such as grenades, molotovs, time +bombs and rocks keep their normal behavior. + +## Building from source + +Requires the .NET 8 SDK and RedLoader installed with its game assemblies +generated. + +```powershell +.\build.ps1 -Install +``` + +Use `-Package` to build `FullAuto.zip` for a release. Pass `-GameDir "path"` if +the game is not found automatically. + +## Troubleshooting + +Check `_RedLoader\Latest.log` in your game folder. FullAuto logs a line when it + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..ce0c413 --- /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\FullAuto.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'FullAuto' + 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 'FullAuto.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'FullAuto') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'FullAuto') -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..aa07d13 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "FullAuto", + "name": "FullAuto", + "author": "JustinOros", + "version": "1.0.0", + "description": "Hold fire to shoot any gun full-auto at a configurable rate", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +}