swb_base/GunPackCodec.cs

Codec for the game's gun audio pack format and an MS ADPCM-to-PCM16 WAV decoder. It reads a .gunpack header, locates indexed WAV blobs, validates WAV fmt/data chunks, supports PCM16 passthrough and decodes Microsoft ADPCM (4-bit) into interleaved 16-bit little-endian PCM for mono or stereo.

Native Interop
using System;

namespace SWB.Base;

/// <summary>
/// The gun audio pack file format, and the one recording format inside it — package trim step 4 (2026-10-03,
/// Docs/STEP4_GUN_PACKS.md). Plain C#, nothing from the engine, so the same file is compiled into the scratch test that
/// checks it against ffmpeg.
///
/// A pack (`sounds/weapons/_packs/*.gunpack`, written by `Tools/gun_packs.py`):
///   "NZGP", version (u32), index length N (u32), N bytes of UTF-8 JSON `{"entries": [[name, offset, length], …]}`,
///   then the recordings' WAV files exactly as they were, back to back. An offset counts from the end of the index.
///
/// ⛔ MS ADPCM IS DECODED HERE, NOT BY THE ENGINE. 7,787 of the 7,792 gun WAVs are MS ADPCM (4-bit). The engine's WAV
/// reader (`SoundFile.FromWav`) is PCM-only and its compressed-data entry point (`SoundFile.Create`) is internal, so a
/// recording becomes 16-bit PCM here and reaches the engine through `SoundFile.FromPcm`. Checked sample for sample
/// against ffmpeg on 300 recordings spread over every gun, before any gun used it.
/// ⚠️ PCM 16 PASSES THROUGH. Anything else (another codec, 3+ channels) returns null; the converter never packs those.
/// </summary>
public static class GunPackCodec
{
	public const int Version = 1;

	static readonly int[] Adapt = { 230, 230, 230, 230, 307, 409, 512, 614, 768, 614, 512, 409, 307, 230, 230, 230 };
	static readonly int[] StdCoef1 = { 256, 512, 0, 192, 240, 460, 392 };
	static readonly int[] StdCoef2 = { 0, -256, 0, 64, 0, -208, -232 };

	static int U16( byte[] b, int i ) => b[i] | (b[i + 1] << 8);
	static int S16( byte[] b, int i ) => (short)(b[i] | (b[i + 1] << 8));
	public static int I32( byte[] b, int i ) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24);

	/// <summary>A pack's header: is it one, and where its index and data are.</summary>
	public static bool ReadHeader( byte[] pack, out int version, out int indexStart, out int indexLength )
	{
		version = indexStart = indexLength = 0;
		if ( pack is null || pack.Length < 12 || pack[0] != 'N' || pack[1] != 'Z' || pack[2] != 'G' || pack[3] != 'P' ) return false;
		version = I32( pack, 4 );
		indexLength = I32( pack, 8 );
		indexStart = 12;
		return indexLength >= 0 && indexStart + indexLength <= pack.Length;
	}

	/// <summary>
	/// One recording (its WAV bytes, at <paramref name="start"/> in <paramref name="b"/>) as interleaved 16-bit PCM,
	/// little-endian. Null when it is not MS ADPCM or PCM 16, mono or stereo.
	/// </summary>
	public static byte[] Decode( byte[] b, int start, int length, out int channels, out int rate )
	{
		channels = 0; rate = 0;
		int end = start + length;
		if ( b is null || length < 12 || end > b.Length || b[start] != 'R' || b[start + 1] != 'I' || b[start + 2] != 'F' || b[start + 3] != 'F' )
			return null;

		int pos = start + 12, tag = 0, align = 0, bits = 0, ncoef = 0, fmt = -1, data = -1, dataLen = 0;
		while ( pos + 8 <= end )
		{
			int size = I32( b, pos + 4 );
			if ( size < 0 ) break;
			if ( b[pos] == 'f' && b[pos + 1] == 'm' && b[pos + 2] == 't' && b[pos + 3] == ' ' )
			{
				fmt = pos + 8;
				tag = U16( b, fmt ); channels = U16( b, fmt + 2 ); rate = I32( b, fmt + 4 );
				align = U16( b, fmt + 12 ); bits = U16( b, fmt + 14 );
				if ( tag == 2 && size >= 22 ) ncoef = U16( b, fmt + 20 );
			}
			else if ( b[pos] == 'd' && b[pos + 1] == 'a' && b[pos + 2] == 't' && b[pos + 3] == 'a' )
			{
				data = pos + 8;
				dataLen = Math.Min( size, end - data );
			}
			pos += 8 + size + (size & 1);
		}
		if ( fmt < 0 || data < 0 || channels < 1 || channels > 2 || rate <= 0 ) return null;

		if ( tag == 1 && bits == 16 )
		{
			var copy = new byte[dataLen & ~1];
			Array.Copy( b, data, copy, 0, copy.Length );
			return copy;
		}
		if ( tag != 2 || align <= 0 ) return null;

		// the file's own coefficient table; every gun WAV carries the standard seven
		int n = Math.Max( ncoef, 7 );
		var c1 = new int[n];
		var c2 = new int[n];
		for ( int i = 0; i < n; i++ )
		{
			bool own = i < ncoef && fmt + 26 + i * 4 <= end;
			c1[i] = own ? S16( b, fmt + 22 + i * 4 ) : StdCoef1[i];
			c2[i] = own ? S16( b, fmt + 24 + i * 4 ) : StdCoef2[i];
		}

		int ch = channels, hdr = 7 * ch;
		long frames = 0;
		for ( int o = 0; o < dataLen; o += align )
		{
			int len = Math.Min( align, dataLen - o );
			if ( len >= hdr ) frames += 2 + (len - hdr) * 2 / ch;
		}

		var pcm = new byte[frames * ch * 2];
		int w = 0;
		for ( int o = 0; o < dataLen; o += align )
		{
			int len = Math.Min( align, dataLen - o );
			if ( len < hdr ) break;
			int k = data + o;
			if ( ch == 1 )
			{
				int p = Math.Min( b[k], n - 1 ), a1 = c1[p], a2 = c2[p];
				int d = S16( b, k + 1 ), s1 = S16( b, k + 3 ), s2 = S16( b, k + 5 );
				Put( pcm, ref w, s2 );
				Put( pcm, ref w, s1 );
				for ( int i = k + 7; i < k + len; i++ )
				{
					int by = b[i];
					Put( pcm, ref w, Nibble( ref s1, ref s2, ref d, a1, a2, by >> 4 ) );
					Put( pcm, ref w, Nibble( ref s1, ref s2, ref d, a1, a2, by & 15 ) );
				}
			}
			else
			{
				int pl = Math.Min( b[k], n - 1 ), pr = Math.Min( b[k + 1], n - 1 );
				int l1 = c1[pl], l2 = c2[pl], r1 = c1[pr], r2 = c2[pr];
				int dl = S16( b, k + 2 ), dr = S16( b, k + 4 );
				int s1l = S16( b, k + 6 ), s1r = S16( b, k + 8 ), s2l = S16( b, k + 10 ), s2r = S16( b, k + 12 );
				Put( pcm, ref w, s2l ); Put( pcm, ref w, s2r );
				Put( pcm, ref w, s1l ); Put( pcm, ref w, s1r );
				for ( int i = k + 14; i < k + len; i++ )
				{
					int by = b[i];
					Put( pcm, ref w, Nibble( ref s1l, ref s2l, ref dl, l1, l2, by >> 4 ) );
					Put( pcm, ref w, Nibble( ref s1r, ref s2r, ref dr, r1, r2, by & 15 ) );
				}
			}
		}
		return pcm;
	}

	static void Put( byte[] pcm, ref int w, int s )
	{
		pcm[w++] = (byte)s;
		pcm[w++] = (byte)(s >> 8);
	}

	/// <summary>One 4-bit step, as the MS ADPCM reference and ffmpeg do it: `/ 256` truncates toward zero.</summary>
	static int Nibble( ref int s1, ref int s2, ref int delta, int c1, int c2, int nib )
	{
		int pred = (s1 * c1 + s2 * c2) / 256 + ((nib & 8) != 0 ? nib - 16 : nib) * delta;
		if ( pred > 32767 ) pred = 32767; else if ( pred < -32768 ) pred = -32768;
		s2 = s1;
		s1 = pred;
		delta = (Adapt[nib] * delta) >> 8;
		if ( delta < 16 ) delta = 16;
		return pred;
	}
}