Tools/WeaponPlacement.cs

Utility class that stores and applies per-weapon placement overrides (positions and angles) for viewmodel/ADS/run/customize/muzzle/hands. It reads a shipped JSON fallback from assets, prefers a writable user file under FileSystem.Data, saves edits immediately, and provides console commands to list, clear, export, and tweak the held weapon's muzzle offset.

File Access
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Sandbox;
using SWB.Base;
using SWB.Shared;

namespace NZombies;

/// <summary>
/// Per-weapon placement overrides, saved from the offset editor and applied on spawn.
///
/// ⛔ THE PREFAB CANNOT BE WRITTEN AT RUNTIME. Assets are read-only to the running
/// game, so "align it and save" has to land somewhere writable — FileSystem.Data —
/// and be re-applied when the weapon spawns.
///
/// ⚠️ THE POINT IS TO REMOVE THE HUMAN ROUND-TRIP. Aligning ADS produced numbers
/// that had to be copied out of the console and pasted into a prefab by someone
/// else; every weapon cost a message. Saving here means aligning IS the fix.
///
/// Overrides win over the prefab, so a weapon that was never aligned is unaffected.
/// </summary>
public static class WeaponPlacement
{
	/// <summary>Where a mapper's own alignments are written — per-user and writable.</summary>
	const string FILE = "weapon_placement.json";

	/// <summary>
	/// The SHIPPED alignments, under Assets, read through `FileSystem.Mounted`.
	///
	/// ⛔ A PUBLISHED COPY HAS AN EMPTY `FileSystem.Data`, so without this every gun in the
	/// published game sits wherever its prefab happens to put it — months of hand-alignment
	/// present on the authoring machine and nowhere else. It is the same failure the map configs
	/// had on 2026-09-02, in the same shape, for the same reason.
	///
	/// ⚠️ UNDER `weapons/` DELIBERATELY: `weapons/*.json` is already in the .sbproj's
	/// `Resources`, so the shipped copy rides a glob that is known to work rather than needing a
	/// new one that might not.
	/// </summary>
	const string SHIPPED = "weapons/placement.json";

	public record Entry( string Pos, string Angle );

	static Dictionary<string, Dictionary<string, Entry>> _data;

	/// <summary>slot names as they appear in the editor / prefab.</summary>
	public const string Aim = "AimAnimData";
	public const string Hip = "ViewModelOffset";
	public const string Run = "RunAnimData";

	/// <summary>
	/// ⛔ ADDED BECAUSE THE EDITOR COULD SELECT THIS POSE BUT NEVER SAVE IT. Four buttons load a
	/// pose and only three had a slot, so CustomizeAnimData fell through to whatever slot was
	/// selected before it -- writing one pose's numbers into another pose's entry.
	/// </summary>
	public const string Customize = "CustomizeAnimData";

	/// <summary>
	/// The two per-weapon corrections that are NOT poses.
	/// </summary>
	///
	/// ⚠️ THEY RIDE THE SAME STORE BECAUSE THEY ARE THE SAME SHAPE — one `AngPos` per weapon,
	/// saved by name, applied on spawn. A second file for two more offsets would be a second thing
	/// to export, clear and keep in step.
	public const string Muzzle = "MuzzleOffset";
	public const string Hands = "HandsOffset";

	static Dictionary<string, Dictionary<string, Entry>> Data
	{
		get
		{
			if ( _data is not null ) return _data;
			_data = new();
			// ⚠️ DATA WINS WHOLESALE, NOT PER WEAPON. Merging the shipped set under the
			// mapper's would resurrect an override they had deliberately DELETED — removing an
			// entry is how you say "use the prefab's own numbers". Same rule as `MapConfig.Load`.
			BaseFileSystem fs = null;
			string path = null;

			if ( FileSystem.Data.FileExists( FILE ) ) { fs = FileSystem.Data; path = FILE; }
			else if ( FileSystem.Mounted.FileExists( SHIPPED ) ) { fs = FileSystem.Mounted; path = SHIPPED; }

			if ( fs is not null )
			{
				try
				{
					_data = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, Entry>>>(
						fs.ReadAllText( path ) ) ?? new();

					Log.Info( $"[placement] {_data.Count} weapon(s) aligned "
						+ $"[{( fs == FileSystem.Data ? "user" : "shipped" )}]" );
				}
				catch ( System.Exception e )
				{
					Log.Warning( $"[placement] {path} unreadable: {e.Message}" );
				}
			}
			else
			{
				// ⛔ SILENCE HERE WAS THE WHOLE PROBLEM. Nothing said the alignments were
				// missing, so the published build read as "the guns are just positioned badly".
				Log.Warning( $"[placement] no alignments found — neither {FILE} (user) nor "
					+ $"{SHIPPED} (shipped). Every weapon will sit where its prefab puts it. "
					+ "In a published build run nz_publish_check." );
			}
			return _data;
		}
	}

	/// <summary>Store one slot for one weapon and write the file immediately.</summary>
	public static void Save( Weapon weapon, string slot, AngPos value )
	{
		if ( !weapon.IsValid() ) { Log.Info( "[placement] no weapon" ); return; }

		var key = weapon.ClassName;
		if ( !Data.TryGetValue( key, out var slots ) )
			Data[key] = slots = new();

		slots[slot] = new Entry(
			$"{value.Pos.x:0.####},{value.Pos.y:0.####},{value.Pos.z:0.####}",
			$"{value.Angle.pitch:0.####},{value.Angle.yaw:0.####},{value.Angle.roll:0.####}" );

		// ⚠️ Written on every save, not on shutdown. A crash or a play-mode stop
		// after aligning ten weapons must not lose them.
		FileSystem.Data.WriteAllText( FILE,
			JsonSerializer.Serialize( Data, new JsonSerializerOptions { WriteIndented = true } ) );

		Log.Info( $"[placement] saved {key}.{slot} = Pos {slots[slot].Pos}  Angle {slots[slot].Angle}" );
	}

	/// <summary>Push any saved slots onto a weapon. Called when it spawns.</summary>
	public static void Apply( Weapon weapon )
	{
		if ( !weapon.IsValid() ) return;
		if ( !Data.TryGetValue( weapon.ClassName, out var slots ) ) return;

		foreach ( var (slot, e) in slots )
		{
			var ap = Parse( e );
			switch ( slot )
			{
				case Aim: weapon.AimAnimData = ap; break;
				case Hip: weapon.ViewModelOffset = ap; break;
				case Run: weapon.RunAnimData = ap; break;
				case Customize: weapon.CustomizeAnimData = ap; break;
				case Muzzle: weapon.MuzzleOffset = ap; break;
				case Hands: weapon.HandsOffset = ap; break;
			}
		}
		Log.Info( $"[placement] applied {slots.Count} override(s) to {weapon.ClassName}" );
	}

	static AngPos Parse( Entry e )
	{
		float[] P( string s ) => s.Split( ',' ).Select( x => x.ToFloat() ).ToArray();
		var p = P( e.Pos );
		var a = P( e.Angle );
		return new AngPos
		{
			Pos = new Vector3( p[0], p[1], p[2] ),
			Angle = new Angles( a[0], a[1], a[2] ),
		};
	}

	/// <summary>
	/// `nz_muzzle_offset [x y z] [pitch yaw roll]` — move the HELD weapon's muzzle effects.
	/// </summary>
	///
	/// ⚠️ IN THE MUZZLE ATTACHMENT'S OWN FRAME, so x runs down the barrel whichever way the
	/// player faces. Absolute, not cumulative. With no arguments it reports.
	///
	/// ⚠️ IT SAVES IMMEDIATELY, because the alternative is dialling a number in and losing it to
	/// the next restart — which is exactly what happened to the sights' correction earlier today.
	///
	/// ⚠️ THE HELD WEAPON IS FOUND BY SCENE QUERY, matching `SWB.Editor.Commands`. That is a
	/// `FirstOrDefault` over weapons, which the project bans for PLAYER lookups; a console command
	/// that edits what the person typing it is holding has one answer by construction.
	[ConCmd( "nz_muzzle_offset" )]
	public static void MuzzleOffsetCmd( float x = float.NaN, float y = 0f, float z = 0f,
		float pitch = float.NaN, float yaw = 0f, float roll = 0f )
	{
		var w = Held();
		if ( !w.IsValid() ) { Log.Warning( "[placement] hold a weapon first" ); return; }

		if ( !float.IsNaN( x ) || !float.IsNaN( pitch ) )
		{
			w.MuzzleOffset = new AngPos
			{
				Pos = float.IsNaN( x ) ? w.MuzzleOffset.Pos : new Vector3( x, y, z ),
				Angle = float.IsNaN( pitch ) ? w.MuzzleOffset.Angle : new Angles( pitch, yaw, roll ),
			};

			Save( w, Muzzle, w.MuzzleOffset );
		}

		Log.Info( $"[placement] {w.ClassName} muzzle offset pos {w.MuzzleOffset.Pos}"
			+ $" angle {w.MuzzleOffset.Angle}" );
	}

	/// <summary>The weapon the person typing is holding.</summary>
	static Weapon Held()
		=> Game.ActiveScene?.GetAllComponents<Weapon>()
			.FirstOrDefault( w => w.IsValid() && w.Active );

	/// <summary>Show everything saved so far. `nz_placement_list`.</summary>
	[ConCmd( "nz_placement_list" )]
	public static void List()
	{
		if ( Data.Count == 0 ) { Log.Info( "[placement] nothing saved" ); return; }
		foreach ( var (w, slots) in Data )
			foreach ( var (slot, e) in slots )
				Log.Info( $"[placement] {w,-12} {slot,-16} Pos {e.Pos}   Angle {e.Angle}" );
		Log.Info( $"[placement] file: {FILE} (FileSystem.Data)" );
	}

	/// <summary>Forget a weapon's overrides. `nz_placement_clear galil` / `all`.</summary>
	[ConCmd( "nz_placement_clear" )]
	public static void Clear( string weapon = "" )
	{
		if ( weapon == "all" ) Data.Clear();
		else
		{
			var key = weapon.StartsWith( "nz_" ) ? weapon : $"nz_{weapon}";
			if ( !Data.Remove( key ) ) { Log.Info( $"[placement] nothing saved for {key}" ); return; }
		}
		FileSystem.Data.WriteAllText( FILE,
			JsonSerializer.Serialize( Data, new JsonSerializerOptions { WriteIndented = true } ) );
		Log.Info( $"[placement] cleared {(weapon == "all" ? "everything" : weapon)}" );
	}

	/// <summary>
	/// Dump every override as prefab-ready JSON, for baking them in permanently.
	/// `nz_placement_export`.
	///
	/// ⚠️ The saved file is the LIVE fix; this is for folding the values back into
	/// the prefabs so a fresh install has them without the data file.
	/// </summary>
	[ConCmd( "nz_placement_export" )]
	public static void Export()
	{
		foreach ( var (w, slots) in Data )
		{
			Log.Info( $"--- {w} ---" );
			foreach ( var (slot, e) in slots )
				Log.Info( $"  \"{slot}\": {{ \"Angle\": \"{e.Angle}\", \"Pos\": \"{e.Pos}\" }}" );
		}
	}
}