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.
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}\" }}" );
}
}
}