commit 9c1ac495bccbe59c7c460e986fe9519254eadad5 Author: Justin Oros Date: Thu Sep 24 21:21:14 2026 -0700 Initial commit of RigProbe skeleton comparison mod diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ba626a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +dist/ +*.zip +*.user +*.suo +.vs/ diff --git a/RigProbe.cs b/RigProbe.cs new file mode 100644 index 0000000..5ca2499 --- /dev/null +++ b/RigProbe.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Endnight.Animation; +using RedLoader; +using RedLoader.Utils; +using Sons.Ai.Vail; +using SonsSdk; +using SonsSdk.Attributes; +using TheForest.Utils; +using UnityEngine; + +namespace RigProbe; + +public class RigProbe : SonsMod +{ + private static readonly Dictionary PlayerParents = new(); + private static readonly Dictionary PlayerPaths = new(); + + protected override void OnSdkInitialized() + { + RLog.Msg("RigProbe loaded. Commands: rigprobe [filter], rigspawn [variation]"); + } + + [DebugCommand("rigprobe")] + private static void ProbeCommand(string args) + { + try + { + Probe((args ?? string.Empty).Trim()); + } + catch (Exception e) + { + RLog.Error($"RigProbe failed: {e}"); + } + } + + [DebugCommand("rigspawn")] + private static void SpawnCommand(string args) + { + var parts = (args ?? string.Empty).Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0 || !Enum.TryParse(parts[0], true, out var id)) + { + Say("Usage: rigspawn [variation] e.g. rigspawn Brandy"); + return; + } + + var variation = parts.Length > 1 && int.TryParse(parts[1], out var v) ? v : 0; + var player = LocalPlayer.GameObject; + if (!player) + { + Say("Load into a game first"); + return; + } + + try + { + var t = player.transform; + var actor = ActorTools.Spawn(id, t.position + t.forward * 4f, variation); + Say(actor ? $"Spawned {id} variation {variation}" : $"Spawn returned null for {id}"); + } + catch (Exception e) + { + RLog.Error($"rigspawn {id} failed: {e.Message}"); + } + } + + private static void Probe(string filter) + { + var player = LocalPlayer.GameObject; + var race = LocalPlayer.RaceSystem; + if (!player || !race) + { + Say("Load into a game first"); + return; + } + + var root = race._animationRoot; + if (!root) + root = player.transform.Find("PlayerAnimator/Root"); + if (!root) + { + Say("Could not find the player animation root"); + return; + } + + BuildPlayerRig(root); + + var sb = new StringBuilder(); + sb.AppendLine($"RigProbe {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"Player animation root: {HierPath(root)}"); + sb.AppendLine($"Player rig transforms: {PlayerPaths.Count}"); + sb.AppendLine($"Current race: {race.CurrentRace}"); + try + { + sb.AppendLine($"ExpressionBlendsStartIndex: {race.GetExpressionBlendsStartIndex()}"); + } + catch (Exception e) + { + sb.AppendLine($"ExpressionBlendsStartIndex: error {e.Message}"); + } + sb.AppendLine($"Head: {Name(race.GetHead())} LeftArm: {Name(race.GetLeftArm())} RightArm: {Name(race.GetRightArm())}"); + sb.AppendLine(); + + sb.AppendLine("==== PLAYER RENDERERS ===="); + DumpObject(sb, "Player", player, true); + sb.AppendLine(); + + sb.AppendLine("==== ACTORS ===="); + var ids = Enum.GetValues(typeof(VailActorTypeId)).Cast() + .Where(i => i != VailActorTypeId.None) + .Where(i => filter.Length == 0 || i.ToString().IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0) + .ToList(); + + int probed = 0; + foreach (var id in ids) + { + VailActor prefab = null; + try + { + prefab = ActorTools.GetPrefab(id); + } + catch (Exception e) + { + sb.AppendLine($"[{id} prefab] error {e.Message}"); + } + if (prefab) + { + DumpObject(sb, $"{id} prefab", prefab.gameObject, false); + probed++; + } + + VailActor live = null; + try + { + live = ActorTools.GetActors(id)?.FirstOrDefault(a => a); + } + catch (Exception e) + { + sb.AppendLine($"[{id} live] error {e.Message}"); + } + if (live) + { + DumpObject(sb, $"{id} live", live.gameObject, false); + probed++; + } + } + + var dir = LoaderEnvironment.UserDataDirectory; + var outPath = Path.Combine(dir, "RigProbe.txt"); + File.WriteAllText(outPath, sb.ToString()); + + var rig = new StringBuilder(); + foreach (var kv in PlayerPaths.OrderBy(k => k.Value)) + rig.AppendLine(kv.Value); + File.WriteAllText(Path.Combine(dir, "RigProbe_PlayerRig.txt"), rig.ToString()); + + Say($"RigProbe: {probed} actor objects written to {outPath}"); + } + + private static void BuildPlayerRig(Transform root) + { + PlayerParents.Clear(); + PlayerPaths.Clear(); + foreach (var t in root.GetComponentsInChildren(true)) + { + if (PlayerPaths.ContainsKey(t.name)) + continue; + PlayerPaths[t.name] = RelPath(t, root); + PlayerParents[t.name] = t.parent ? t.parent.name : string.Empty; + } + } + + private static void DumpObject(StringBuilder sb, string label, GameObject go, bool allBlendShapes) + { + var renderers = go.GetComponentsInChildren(true); + var swaps = go.GetComponentsInChildren(true); + + int totalBones = 0, totalName = 0, totalParent = 0, remapCount = 0; + var body = new StringBuilder(); + + foreach (var r in renderers) + { + var a = Analyze(r, allBlendShapes); + totalBones += a.Bones; + totalName += a.NameMatch; + totalParent += a.ParentMatch; + if (a.HasRemap) + remapCount++; + body.Append(a.Text); + } + + var summary = $"[{label}] go={go.name} renderers={renderers.Length} bones={totalBones} " + + $"nameMatch={Pct(totalName, totalBones)} parentMatch={Pct(totalParent, totalBones)} " + + $"remapCaches={remapCount} meshSwappers={swaps.Length}"; + sb.AppendLine(summary); + foreach (var s in swaps) + { + string asset; + try + { + asset = s._meshAsset ? s._meshAsset.name : "null"; + } + catch + { + asset = "?"; + } + sb.AppendLine($" swapper {HierPath(s.transform)} meshAsset={asset}"); + } + sb.Append(body); + sb.AppendLine(); + RLog.Msg(summary); + } + + private sealed class Result + { + public int Bones; + public int NameMatch; + public int ParentMatch; + public bool HasRemap; + public string Text; + } + + private static Result Analyze(SkinnedMeshRenderer r, bool allBlendShapes) + { + var res = new Result(); + var missing = new List(); + var parentDiff = new List(); + int nulls = 0; + + var bones = r.bones; + if (bones != null) + { + foreach (var b in bones) + { + if (!b) + { + nulls++; + continue; + } + res.Bones++; + if (PlayerParents.TryGetValue(b.name, out var expectedParent)) + { + res.NameMatch++; + var actualParent = b.parent ? b.parent.name : string.Empty; + if (actualParent == expectedParent) + res.ParentMatch++; + else + parentDiff.Add($"{b.name}({actualParent}!={expectedParent})"); + } + else + { + missing.Add(b.name); + } + } + } + + var sb = new StringBuilder(); + var mesh = r.sharedMesh; + int blendCount = mesh ? mesh.blendShapeCount : 0; + + sb.AppendLine($" SMR {HierPath(r.transform)} active={r.gameObject.activeInHierarchy} mesh={(mesh ? mesh.name : "null")} " + + $"verts={(mesh ? mesh.vertexCount : 0)} bones={res.Bones} null={nulls} nameMatch={res.NameMatch} " + + $"parentMatch={res.ParentMatch} rootBone={(r.rootBone ? r.rootBone.name : "null")} blendShapes={blendCount} " + + $"materials={string.Join(",", r.sharedMaterials.Where(m => m).Select(m => m.name))}"); + + var remap = r.GetComponent(); + if (remap) + { + res.HasRemap = true; + sb.AppendLine($" remap compat={remap._isCompatible} rootPath={remap._rootBonePath} " + + $"paths={(remap._bonePaths != null ? remap._bonePaths.Count : 0)} " + + $"srcBase={remap._sourceTransformBasePath} base={remap._transformBasePath}"); + } + + if (missing.Count > 0) + sb.AppendLine($" missing({missing.Count}): {string.Join(" ", missing.Take(60))}"); + if (parentDiff.Count > 0) + sb.AppendLine($" parentDiff({parentDiff.Count}): {string.Join(" ", parentDiff.Take(30))}"); + + if (mesh && blendCount > 0) + { + var take = allBlendShapes ? blendCount : Math.Min(blendCount, 15); + var names = new List(); + for (int i = 0; i < take; i++) + names.Add($"{i}:{mesh.GetBlendShapeName(i)}"); + sb.AppendLine($" blendShapes: {string.Join(" ", names)}{(take < blendCount ? " ..." : string.Empty)}"); + } + + res.Text = sb.ToString(); + return res; + } + + private static string Pct(int a, int b) + { + return b == 0 ? "n/a" : $"{a}/{b} ({a * 100 / b}%)"; + } + + private static string Name(GameObject go) + { + return go ? go.name : "null"; + } + + private static string HierPath(Transform t) + { + var parts = new List(); + while (t) + { + parts.Add(t.name); + t = t.parent; + } + parts.Reverse(); + return string.Join("/", parts); + } + + private static string RelPath(Transform t, Transform root) + { + var parts = new List(); + while (t && t != root) + { + parts.Add(t.name); + t = t.parent; + } + parts.Reverse(); + return string.Join("/", parts); + } + + private static void Say(string text) + { + SonsTools.ShowMessage(text, 6f); + RLog.Msg(text); + } +} diff --git a/RigProbe.csproj b/RigProbe.csproj new file mode 100644 index 0000000..9b1592a --- /dev/null +++ b/RigProbe.csproj @@ -0,0 +1,37 @@ + + + + net6 + enable + latest + RigProbe + RigProbe + 1.0.0 + CS0436;CS1701;CS1702;MSB3277;MSB3106 + + + + $(SOTF_PATH) + + + + + + + + + + + + + + + + + + + + + diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..732b988 --- /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\RigProbe.dll' + +if ($Install) { + $modsDir = Join-Path $GameDir 'Mods' + $modDataDir = Join-Path $modsDir 'RigProbe' + 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 'RigProbe.zip' + + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Force -Path (Join-Path $stage 'RigProbe') | Out-Null + Copy-Item $dll -Destination $stage -Force + Copy-Item (Join-Path $PSScriptRoot 'manifest.json') -Destination (Join-Path $stage 'RigProbe') -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..0b70d3b --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://raw.githubusercontent.com/ToniMacaroni/RedLoader/main/MetadataSchema.json", + "id": "RigProbe", + "name": "RigProbe", + "author": "JustinOros", + "version": "0.1.0", + "description": "Dumps NPC skinned mesh bones and compares them to the player rig", + "gameVersion": "1.0.0", + "platform": "Client", + "type": "Mod" +}