Character Select mod for Sons of the Forest

This commit is contained in:
Justin Oros
2026-09-13 11:04:45 -07:00
commit 98f13e4a71
6 changed files with 355 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
dist/
*.zip
*.user
*.suo
.vs/
+94
View File
@@ -0,0 +1,94 @@
using System;
using System.IO;
using RedLoader;
using RedLoader.Utils;
using Sons.Wearable.Race;
using SonsSdk;
using TheForest.Utils;
using UnityEngine;
namespace CharacterSelect;
public class CharacterSelect : SonsMod
{
private const int RaceCount = 8;
private static int _index;
private static bool _applied;
private static string _configPath;
public CharacterSelect()
{
OnUpdateCallback = OnUpdate;
}
protected override void OnSdkInitialized()
{
_configPath = Path.Combine(LoaderEnvironment.UserDataDirectory, "CharacterSelect.txt");
Load();
SdkEvents.OnAfterSpawn.Subscribe(OnSpawned);
RLog.Msg($"CharacterSelect loaded. Saved race: {_index} ({(PlayerRace.Race)_index}). F9 cycles.");
}
private void OnSpawned()
{
_applied = false;
}
private void OnUpdate()
{
var system = LocalPlayer.RaceSystem;
if (!system)
return;
if (!_applied)
{
_applied = true;
Apply(system, false);
return;
}
if (Input.GetKeyDown(KeyCode.F9))
{
_index = (_index + 1) % RaceCount;
Apply(system, true);
Save();
}
}
private void Apply(PlayerRaceSystem system, bool announce)
{
var race = (PlayerRace.Race)_index;
system.ApplyRace(race);
if (announce)
SonsTools.ShowMessage($"{_index}: {race}");
RLog.Msg($"Applied race {_index} ({race})");
}
private static void Load()
{
try
{
if (!File.Exists(_configPath))
return;
if (int.TryParse(File.ReadAllText(_configPath).Trim(), out var v) && v >= 0 && v < RaceCount)
_index = v;
}
catch (Exception e)
{
RLog.Error($"CharacterSelect could not read config: {e.Message}");
}
}
private static void Save()
{
try
{
File.WriteAllText(_configPath, _index.ToString());
}
catch (Exception e)
{
RLog.Error($"CharacterSelect could not write config: {e.Message}");
}
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<AssemblyName>CharacterSelect</AssemblyName>
<RootNamespace>CharacterSelect</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>
+47
View File
@@ -0,0 +1,47 @@
# Character Select
Pick your player appearance in Sons of the Forest and keep it across sessions.
The game assigns one of eight player appearances at random when you join a server,
and only remembers it if the world gets saved. This mod lets you choose, and
reapplies your choice every time you spawn.
## Notes
Client side only. Nothing needs to be installed on a dedicated server.
Your appearance replicates through normal game networking, so other players see
your choice even if they do not have the mod installed. They will not be able to
change their own appearance without it.
## Requirements
RedLoader
## Installation
1. Download the latest release
2. Extract into your Sons of the Forest folder so you end up with:
- `Mods/CharacterSelect.dll`
- `Mods/CharacterSelect/manifest.json`
## Usage
Press F9 to cycle through the eight appearances. Your choice is saved to
`_RedLoader/UserData/CharacterSelect.txt` and applied automatically on every spawn.
| Value | Name |
| --- | --- |
| 0 | White |
| 1 | Black |
| 2 | Latin |
| 3 | Asian |
| 4 | BlackA |
| 5 | WhiteA |
| 6 | BlackB |
| 7 | LatinA |
## Building
Requires the .NET 8 SDK and a RedLoader install that has been launched once, so
the interop assemblies exist. The script offers to install both if they are missing.
+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\CharacterSelect.dll'
if ($Install) {
$modsDir = Join-Path $GameDir 'Mods'
$modDataDir = Join-Path $modsDir 'CharacterSelect'
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 'CharacterSelect.zip'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
New-Item -ItemType Directory -Force -Path (Join-Path $stage 'CharacterSelect') | Out-Null
Copy-Item $dll -Destination $stage -Force
Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'CharacterSelect') -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": "CharacterSelect",
"name": "CharacterSelect",
"author": "JustinOros",
"version": "1.0.0",
"description": "Pick your player appearance and keep it across sessions",
"gameVersion": "1.0.0",
"platform": "Client",
"type": "Mod"
}