Initial commit

This commit is contained in:
Justin Oros
2026-09-15 10:43:47 -07:00
commit a143b2a9dc
6 changed files with 550 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
obj/
dist/
*.zip
+253
View File
@@ -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<string> Excluded = new()
{
"GrenadeWeaponController",
"MolotovWeaponController",
"TimeBombWeaponController",
"SmallRockWeaponController",
"RopeGunController"
};
private static readonly Dictionary<IntPtr, bool> Allowed = new();
private static readonly HashSet<string> 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;
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AssemblyName>FullAuto</AssemblyName>
<RootNamespace>FullAuto</RootNamespace>
<Version>1.0.0</Version>
<NoWarn>CS0436;CS1701;CS1702;MSB3277;MSB3106</NoWarn>
</PropertyGroup>
<PropertyGroup Condition="'$(GameDir)' == ''">
<GameDir>$(SOTF_PATH)</GameDir>
</PropertyGroup>
<Target Name="CheckGameDir" BeforeTargets="BeforeBuild">
<Error Condition="'$(GameDir)' == ''"
Text="GameDir is not set. Run build.ps1, pass -p:GameDir=&quot;path&quot;, or set SOTF_PATH." />
<Error Condition="!Exists('$(GameDir)\_RedLoader\Game\Sons.dll')"
Text="No RedLoader interop assemblies at $(GameDir)\_RedLoader\Game. Install RedLoader and launch the game once." />
</Target>
<ItemGroup>
<Reference Include="$(GameDir)\_RedLoader\Game\*.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\RedLoader.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\SonsSdk.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\Il2CppInterop.Runtime.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\Il2CppInterop.Common.dll" Private="false" />
<Reference Include="$(GameDir)\_RedLoader\net6\0Harmony.dll" Private="false" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="manifest.json" />
</ItemGroup>
</Project>
+86
View File
@@ -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
+159
View File
@@ -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
}
+11
View File
@@ -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"
}