Weapons/BulletImpactCheck.cs

A static utility with two console commands for debugging bullet impact decals. nz_impact_check traces from the player to report the surface, impact prefab, bullet sound and counts live bullet_decal objects. nz_impact_test traces and calls the weapon system to spawn a bullet impact at the hit point, logging the result.

File Access
using Sandbox;
using System.Linq;

namespace NZombies;

/// <summary>
/// Does the thing you are looking at supply a bullet hole?
///
/// ⛔ DECALS ARE NOT DRAWN BY THE WEAPON — THEY COME FROM THE SURFACE.
/// `Weapon.CreateBulletImpact` clones `tr.Surface.PrefabCollection.BulletImpact` and
/// returns early when there is none. So "no bullet holes" has two completely
/// different causes with one symptom: the impact code never ran, or it ran and the
/// surface had nothing to give. On a ported BSP most faces resolve to `default`,
/// which may or may not carry an impact prefab — and guessing which costs a round of
/// building the wrong fix.
/// </summary>
public static class BulletImpactCheck
{
	/// <summary>
	/// What the crosshair is on, and whether it can produce a hole:
	/// `nz_impact_check`.
	/// </summary>
	[ConCmd( "nz_impact_check" )]
	public static void Check()
	{
		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();

		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;

		var tr = Game.ActiveScene.Trace.Ray( eye, eye + fwd * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Info( "[nz] impact: nothing in front of you" ); return; }

		var surf = tr.Surface;
		var impact = surf?.PrefabCollection.BulletImpact;
		var sound = surf?.SoundCollection.Bullet;

		Log.Info( $"[nz] impact @ {tr.Distance:0}u — {tr.GameObject?.Name}" );
		Log.Info( $"[nz]   surface      '{surf?.ResourceName ?? "NONE"}'" );
		Log.Info( $"[nz]   BulletImpact {(impact.IsValid() ? impact.Name : "NULL — no decal possible")}" );
		Log.Info( $"[nz]   bullet sound {(sound is null ? "none (falls back to generic)" : sound.ResourceName)}" );

		// ⚠️ Counts what is actually in the scene, because "the code ran" and "a hole
		// is on the wall" are still different claims — the clone could be spawning and
		// being destroyed, or spawning somewhere else entirely.
		var live = Game.ActiveScene?.GetAllObjects( true )
			.Count( o => o.Name == "bullet_decal" ) ?? 0;

		Log.Info( $"[nz]   decals alive in scene: {live}  (each lasts 30s)" );

		if ( !impact.IsValid() )
			Log.Warning( "[nz]   -> this surface cannot make holes; a fallback decal is needed" );
	}

	/// <summary>
	/// Fire one bullet's worth of impact where you are looking: `nz_impact_test`.
	///
	/// ⚠️ Calls the SAME method the bullet path calls, so a hole appearing here proves
	/// the decal pipeline end to end without needing to hit anything while shooting.
	/// </summary>
	[ConCmd( "nz_impact_test" )]
	public static void Test()
	{
		var player = NZPlayer.Local;
		var controller = player?.Components.Get<PlayerController>();

		if ( !player.IsValid() ) { Log.Warning( "[nz] no player" ); return; }

		var eye = controller?.EyePosition ?? player.WorldPosition + Vector3.Up * 64f;
		var fwd = (controller?.EyeAngles.ToRotation() ?? player.WorldRotation).Forward;

		var tr = Game.ActiveScene.Trace.Ray( eye, eye + fwd * 4096f )
			.IgnoreGameObjectHierarchy( player.GameObject )
			.Run();

		if ( !tr.Hit ) { Log.Info( "[nz] nothing to hit" ); return; }

		var go = SWB.Base.Weapon.CreateBulletImpact( tr );

		Log.Info( go.IsValid()
			? $"[nz] decal spawned '{go.Name}' at {tr.HitPosition} on '{tr.Surface?.ResourceName}'"
			: $"[nz] NO decal — surface '{tr.Surface?.ResourceName}' has no BulletImpact prefab" );
	}
}