Weapons/WeaponDisplay.cs

Utility for selecting a display model for weapons. It maps a viewmodel path to a corresponding "_display.vmdl" model when present and loadable, caches results per path, and exposes helpers to get or load the chosen model and a console command to report mappings.

File Access
using Sandbox;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// THE MODEL A GUN IS SHOWN WITH ANYWHERE BUT THE HAND: the wall-buy chalk and its reveal, the box's offer, the
/// Pack-a-Punch, the trade and build tables, third person, the fireworks.
///
/// ⛔ A RENDERER WITH NO CLIP PLAYING DRAWS THE BIND POSE, AND AN MW VIEWMODEL'S BIND IS NOT ITS IDLE. Its parts sit
/// displaced and turned and its spare mag is parked 60+ units off (the WSP Swarm's at 5,144). The hand plays idle, so
/// the gun looks right there and comes apart everywhere else. The user, 2026-10-01: *"the chalk for the weapon models
/// its all broken, even though its perfectly good in my hand"*.
///
/// ⚠️ SO EACH MW GUN SHIPS A DISPLAY MODEL BESIDE ITS VIEWMODEL, `weapons/x/x_display.vmdl` next to
/// `weapons/x/v_x.vmdl`, baked by `Tools/mw_display.py`. It is the idle pose as a still mesh, in the bind's frame
/// (X forward, Z up, the gun body where the bind had it), with the parked props left out. The third-person anchors
/// (`j_gun` …) stay where the bind had them, so every display site's placement is unchanged. A gun without one (every
/// other pack) is shown with its viewmodel, as before.
///
/// ⚠️ CACHED PER PATH, MISSES INCLUDED, AND STATIC: it survives a hotload. A display model added while the game runs
/// shows after a restart.
/// </summary>
public static class WeaponDisplay
{
	static readonly Dictionary<string, string> _paths = new();

	/// <summary>The path to show <paramref name="viewModelPath"/> with: its display model when it has one that loads, else itself.</summary>
	public static string PathFor( string viewModelPath )
	{
		if ( string.IsNullOrEmpty( viewModelPath ) ) return viewModelPath;
		if ( _paths.TryGetValue( viewModelPath, out var hit ) ) return hit;

		var result = viewModelPath;

		// ⚠️ PLAIN STRING OPS, NOT System.IO.Path, WHICH THE WHITELIST REFUSES (INSTRUCTIONS, SB1000)
		var p = viewModelPath.Replace( '\\', '/' );
		int slash = p.LastIndexOf( '/' );
		var file = p[(slash + 1)..];

		if ( file.Length > 7
			&& file.StartsWith( "v_", System.StringComparison.OrdinalIgnoreCase )
			&& file.EndsWith( ".vmdl", System.StringComparison.OrdinalIgnoreCase ) )
		{
			var display = $"{p[..(slash + 1)]}{file[2..^5]}_display.vmdl";

			// ⚠️ ASKED BEFORE IT IS LOADED: Model.Load of a missing path prints ERROR_FILEOPEN (MysteryBox._models), and
			// every gun of every other pack would ask once. A published game has only the compiled `_c`.
			if ( FileSystem.Mounted.FileExists( display ) || FileSystem.Mounted.FileExists( display + "_c" ) )
			{
				// ⚠️ AND ONLY WHEN IT LOADS: Model.Load returns the ERROR model, not null. A display model that did not
				// compile falls back to the viewmodel instead of putting a checkerboard on the wall.
				var m = Model.Load( display );
				if ( m is not null && !m.IsError ) result = display;
				else Log.Warning( $"[weapon-display] {display} is there but does not load: showing {file}" );
			}
		}

		_paths[viewModelPath] = result;
		return result;
	}

	/// <summary>The model to show: <paramref name="viewModel"/>'s display model when it has one, else itself.</summary>
	public static Model For( Model viewModel )
	{
		var path = viewModel?.ResourcePath;
		if ( string.IsNullOrEmpty( path ) ) return viewModel;

		var shown = PathFor( path );
		return shown == path ? viewModel : Model.Load( shown );
	}

	/// <summary>`Model.Load`, preferring the path's display model. The error model for a bad path, as Model.Load gives.</summary>
	public static Model Load( string viewModelPath ) => Model.Load( PathFor( viewModelPath ) );

	/// <summary>`nz_weapon_display [path]`: what a viewmodel path is shown with, or how many display models are in use.</summary>
	[ConCmd( "nz_weapon_display" )]
	public static void Report( string path = "" )
	{
		if ( !string.IsNullOrEmpty( path ) )
		{
			Log.Info( $"[weapon-display] {path} -> {PathFor( path )}" );
			return;
		}

		int shown = 0;
		foreach ( var kv in _paths ) if ( kv.Value != kv.Key ) shown++;
		Log.Info( $"[weapon-display] {_paths.Count} viewmodel path(s) asked, {shown} shown with a display model" );
		foreach ( var kv in _paths )
			if ( kv.Value != kv.Key ) Log.Info( $"   {kv.Key} -> {kv.Value}" );
	}
}