diff --git a/README.md b/README.md index 8d7405d..07dfa1d 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,114 @@ -\# Rifle Scope - - +# Rifle Scope A stronger rifle scope for Sons of the Forest. Aiming down the rifle scope zooms - 5x further than the vanilla scope and shows a full screen scope view with a +crosshair reticle, mil dots and a rangefinder instead of the red dot. You can +also put the pistol suppressor on the rifle for quiet, suppressed shots. -crosshair reticle, mil dots and a rangefinder instead of the red dot. - - - -\## Multiplayer - - +## Multiplayer Client side only. Nothing gets installed on a dedicated server, and other players - do not need the mod. +## Installation - -\## Installation - - - -\### Easy install - - +### Easy install Close the game, open PowerShell and paste: - - ```powershell - irm https://raw.githubusercontent.com/JustinOros/sotf-riflescope/main/Install.ps1 | iex - ``` - - It finds your game, installs RedLoader if needed, and installs the latest - RifleScope. Run it again any time to update. +### Manual install +#### Step 1: Install RedLoader -\### Manual install - - - -\#### Step 1: Install RedLoader - - - -\[RedLoader](https://github.com/ToniMacaroni/RedLoader/releases/latest) is the mod - +[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 RifleScope -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 RifleScope - - - -1\. Download `RifleScope.zip` from the - - \[latest release](https://github.com/JustinOros/sotf-riflescope/releases/latest) - -2\. Extract it into the `Mods` folder inside your game folder - -3\. You should end up with: - - +1. Download `RifleScope.zip` from the + [latest release](https://github.com/JustinOros/sotf-riflescope/releases/latest) +2. Extract it into the `Mods` folder inside your game folder +3. You should end up with: ``` - -Mods\\RifleScope.dll - -Mods\\RifleScope\\manifest.json - +Mods\RifleScope.dll +Mods\RifleScope\manifest.json ``` - - -\## Usage - - +## Usage Equip the rifle and aim. Once zoomed in, the rifle model is hidden and the scope - view is shown. Your shot goes where the crosshair is. - - The red number at the bottom left of the scope is the range in meters to whatever - is under the crosshair. - - The reticle has 10 mil dots in each direction. At 5x each dot is 2 mils, which - covers 20 cm at 100 m. At 10x and higher each dot is 1 mil. Use them to hold over - or to the side at long range. - - Press F1 to open the console, then use these commands: - - | Command | Action | - | --- | --- | - -| `scope` | Show whether the mod is on or off | - -| `scope off` | Turn the mod off and go back to the vanilla scope | - -| `scope on` | Turn the mod back on | - +| `scope` | Show whether the scope is on or off | +| `scope off` | Go back to the vanilla scope. The suppressor is not affected | +| `scope on` | Turn the scope back on | | `scopezoom` | Show the current zoom | - | `scopezoom 5` | Set the zoom as a multiple of the vanilla scope, 1 to 50. The default is 5 | - | `scopetrim` | Show the current vertical trim | - -| `scopetrim 50` | Set the vertical trim, -200 to 200. Positive moves hits down, negative moves hits up. The default is 50 | - +| `scopetrim 60` | Set the vertical trim, -200 to 200. Positive moves hits down, negative moves hits up. The default is 60 | | `scopewind` | Show the current windage | - | `scopewind 20` | Set the windage, -200 to 200. Positive moves hits right, negative moves hits left. The default is 0 | - +| `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 | | `scopedebug` | Toggle a debug readout above the range | +### Suppressor +Run `suppressor on` to put the pistol suppressor on the end of the rifle barrel. +Rifle shots then use the suppressed pistol shot sound. It stays on until you run +`suppressor off`, even after restarting the game. You do not need a suppressor in +your inventory. Trim and windage are angle adjustments, so a setting that is right at one range - stays right at every range. - - -The on or off setting, zoom, trim and windage are saved to `UserData\\RifleScope.txt` in your game - +The on or off setting, suppressor, zoom, trim and windage are saved to `UserData\RifleScope.txt` in your game folder. Setting the zoom to 1 keeps vanilla zoom and the normal scope view, - without the red dot. - - -\## Building from source - - +## Building from source Requires the .NET 8 SDK and RedLoader installed with its game assemblies - generated. - - ```powershell - -.\\build.ps1 -Install - +.\build.ps1 -Install ``` - - Use `-Package` to build `RifleScope.zip` for a release. Pass `-GameDir "path"` if - the game is not found automatically. +## Troubleshooting - -\## Troubleshooting - - - -Check `\_RedLoader\\Latest.log` in your game folder. RifleScope logs a line when it - -loads. - +Check `_RedLoader\Latest.log` in your game folder. RifleScope logs a line when it +loads. \ No newline at end of file diff --git a/RifleScope.cs b/RifleScope.cs index 92a9afe..b6d9151 100644 --- a/RifleScope.cs +++ b/RifleScope.cs @@ -2,9 +2,15 @@ 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; @@ -20,10 +26,16 @@ public class RifleScope : SonsMod private const float DefaultZoom = 5f; private const float MinZoom = 1f; private const float MaxZoom = 50f; - private const float DefaultTrimCm = 50f; + private const float DefaultTrimCm = 60f; private const float MaxTrimCm = 200f; private const float MaxWindCm = 200f; private const float TrimReferenceRange = 50f; + private const string SuppressorModName = "CompactPistolSuppressorMod"; + private const string SuppressedFireEvent = "event:/SotF Events/player sounds/Weapons/PistolTactical/PistolTacticalFire"; + private const string MuzzleName = "RifleScopeMuzzle"; + 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 const int MilDotCount = 10; private const float MilDotSize = 0.4f; private const float MinDotPx = 7f; @@ -44,6 +56,12 @@ public class RifleScope : SonsMod private static float _windCm; private static bool _debug; private static bool _enabled = true; + private static bool _suppressor; + private static string _originalFireEvent; + private static HarmonyLib.Harmony _audioHarmony; + private static bool _loggedParamError; + private static bool _loggedPistolParams; + private static bool _reissuing; private static readonly List<(Renderer Renderer, int Slot, Material Original)> RedDotSlots = new(); private static string _configPath; @@ -101,9 +119,6 @@ public class RifleScope : SonsMod private static void Tick() { - if (!_enabled) - return; - if (!_rifle || !_rifle.gameObject.activeInHierarchy) { _rifle = null; @@ -115,6 +130,9 @@ public class RifleScope : SonsMod return; } + if (!_enabled) + return; + var manager = _rifle._cameraFovManager; if (!manager) { @@ -137,14 +155,274 @@ public class RifleScope : SonsMod continue; _rifle = rifle; _projectile = FindProjectileTransform(rifle); - if (!_overlay) - CreateOverlay(); - HideRedDot(rifle); + _originalFireEvent ??= rifle._gunShotAudioEvent; + if (_enabled) + { + if (!_overlay) + CreateOverlay(); + HideRedDot(rifle); + } + if (_suppressor) + SetSuppressor(rifle, true); return true; } return false; } + private static WeaponMod FindSuppressorMod() + { + foreach (var mod in Resources.FindObjectsOfTypeAll()) + { + if (mod && mod.name == SuppressorModName) + 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) + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + 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("RifleScope.SuppressorAudio"); + + var handler = FindGameType("FMOD_AnimationEventHandler"); + var animPrefix = new HarmonyMethod(typeof(RifleScope).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(RifleScope).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() => + _suppressor && _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; + } + private static Transform FindProjectileTransform(RangedWeaponController rifle) { var constraint = rifle._projectileTransformConstraint; @@ -308,7 +586,6 @@ public class RifleScope : SonsMod settings._FieldOfViewTargetOffset_k__BackingField = original; } RestoreRedDot(); - _rifle = null; } private static Material HiddenFor(Material original) @@ -688,6 +965,24 @@ public class RifleScope : SonsMod Say($"RifleScope is {(_enabled ? "on" : "off")}. Usage: scope on or scope off"); } + [DebugCommand("suppressor")] + private static void SuppressorCommand(string args) + { + args = (args ?? string.Empty).Trim().ToLowerInvariant(); + + if (args != "on" && args != "off") + { + Say($"Rifle suppressor is {(_suppressor ? "on" : "off")}. Usage: suppressor on or suppressor off"); + return; + } + + _suppressor = args == "on"; + Save(); + if (_rifle) + SetSuppressor(_rifle, _suppressor); + Say($"Rifle suppressor {(_suppressor ? "on" : "off")}"); + } + [DebugCommand("scopezoom")] private static void ScopeZoomCommand(string args) { @@ -790,6 +1085,8 @@ public class RifleScope : SonsMod _windCm = wind; if (parts.Length > 3) _enabled = parts[3] != "0"; + if (parts.Length > 4) + _suppressor = parts[4] == "1"; } catch (Exception e) { @@ -801,7 +1098,7 @@ public class RifleScope : SonsMod { try { - File.WriteAllText(_configPath, $"{_zoom.ToString(Inv)} {_trimCm.ToString(Inv)} {_windCm.ToString(Inv)} {(_enabled ? "1" : "0")}"); + File.WriteAllText(_configPath, $"{_zoom.ToString(Inv)} {_trimCm.ToString(Inv)} {_windCm.ToString(Inv)} {(_enabled ? "1" : "0")} {(_suppressor ? "1" : "0")}"); } catch (Exception e) { diff --git a/RifleScope.csproj b/RifleScope.csproj index a35b3cb..74724bb 100644 --- a/RifleScope.csproj +++ b/RifleScope.csproj @@ -6,7 +6,7 @@ latest RifleScope RifleScope - 1.0.1 + 1.1.0 CS0436;CS1701;CS1702;MSB3277;MSB3106 @@ -34,4 +34,4 @@ - + \ No newline at end of file diff --git a/manifest.json b/manifest.json index 968389c..0245f24 100644 --- a/manifest.json +++ b/manifest.json @@ -3,9 +3,9 @@ "id": "RifleScope", "name": "RifleScope", "author": "JustinOros", - "version": "1.0.1", + "version": "1.1.0", "description": "Stronger rifle scope magnification and a crosshair reticle", "gameVersion": "1.0.0", "platform": "Client", "type": "Mod" -} +} \ No newline at end of file