Files
LibBSP/LibBSP/Source/Util/BSPReader.cs
2016-08-30 19:13:22 -06:00

528 lines
18 KiB
C#

using System;
using System.Linq;
using System.IO;
using System.Collections.Generic;
namespace LibBSP {
/// <summary>
/// Handles reading of a BSP file on-demand.
/// </summary>
public class BSPReader {
private FileInfo bspFile;
private Dictionary<int, LumpInfo> lumpFiles = null;
private bool _bigEndian = false;
/// <summary>
/// An XOr encryption key for encrypted map formats. Must be read and set.
/// </summary>
private byte[] key = new byte[0];
/// <summary>
/// Was this map determined to be in big endian format?
/// </summary>
public bool bigEndian { get { return _bigEndian; } set { _bigEndian = value; } }
/// <summary>
/// Creates a new instance of a <see cref="BSPReader"/> class to read the specified file.
/// </summary>
/// <param name="file">The <c>FileInfo</c> representing the file this <see cref="BSPReader"/> should read.</param>
public BSPReader(FileInfo file) {
if (!File.Exists(file.FullName)) {
throw new FileNotFoundException("Unable to open BSP file; file " + file.FullName + " not found.");
} else {
this.bspFile = file;
}
}
/// <summary>
/// Gets the information for lump "<paramref name="index"/>" for this BSP file when reading it as "<paramref name="version"/>".
/// </summary>
/// <param name="index">The numerical index of this lump.</param>
/// <param name="version">The type of BSP to interpret the file as.</param>
/// <returns>A <see cref="LumpInfo"/> object containing information about the lump.</returns>
/// <exception cref="IndexOutOfRangeException">"<paramref name="index"/>" is less than zero, or greater than the number of lumps allowed by "<paramref name="version"/>".</exception>
public LumpInfo GetLumpInfo(int index, MapType version) {
if (index < 0 || index >= BSP.GetNumLumps(version)) {
throw new IndexOutOfRangeException();
}
switch (version) {
case MapType.Quake:
case MapType.Nightfire: {
return GetLumpInfoAtOffset(4 + (8 * index), version);
}
case MapType.Quake2:
case MapType.Daikatana:
case MapType.SiN:
case MapType.SoF:
case MapType.Quake3:
case MapType.Raven:
case MapType.CoD:
case MapType.CoD2: {
return GetLumpInfoAtOffset(8 + (8 * index), version);
}
case MapType.STEF2:
case MapType.STEF2Demo:
case MapType.MOHAA:
case MapType.FAKK: {
return GetLumpInfoAtOffset(12 + (8 * index), version);
}
case MapType.Source17:
case MapType.Source18:
case MapType.Source19:
case MapType.Source20:
case MapType.Source21:
case MapType.Source22:
case MapType.Source23:
case MapType.Source27:
case MapType.L4D2:
case MapType.TacticalInterventionEncrypted:
case MapType.Vindictus:
case MapType.DMoMaM: {
if (lumpFiles == null) {
LoadLumpFiles();
}
if (lumpFiles.ContainsKey(index)) { return lumpFiles[index]; }
return GetLumpInfoAtOffset(8 + (16 * index), version);
}
case MapType.CoD4: {
using (FileStream stream = new FileStream(bspFile.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader binaryReader = new BinaryReader(stream);
stream.Seek(8, SeekOrigin.Begin);
int numlumps = binaryReader.ReadInt32();
int offset = (numlumps * 8) + 12;
for (int i = 0; i < numlumps; i++) {
int id = binaryReader.ReadInt32();
int length = binaryReader.ReadInt32();
if (id == index) {
return new LumpInfo() {
offset = offset,
length = length
};
} else {
offset += length;
while (offset % 4 != 0) { offset++; }
}
}
binaryReader.Close();
}
return new LumpInfo();
}
default: {
return null;
}
}
}
/// <summary>
/// Gets the lump information at offset "<paramref name="offset"/>" for this BSP file when reading it as "<paramref name="version"/>".
/// </summary>
/// <param name="offset">The offset of the lump's information.</param>
/// <param name="version">The type of BSP to interpret the file as.</param>
/// <returns>A <see cref="LumpInfo"/> object containing information about the lump.</returns>
private LumpInfo GetLumpInfoAtOffset(int offset, MapType version) {
if (bspFile.Length < offset + 16) {
return new LumpInfo();
}
byte[] input;
using (FileStream stream = new FileStream(bspFile.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader binaryReader = new BinaryReader(stream);
stream.Seek(offset, SeekOrigin.Begin);
input = binaryReader.ReadBytes(16);
binaryReader.Close();
}
if (version == MapType.TacticalInterventionEncrypted) {
input = XorWithKeyStartingAtIndex(input, offset);
}
int lumpOffset = 0;
int lumpLength = 0;
int lumpVersion = 0;
int lumpIdent = 0;
if (version == MapType.L4D2) {
lumpVersion = BitConverter.ToInt32(input, 0);
lumpOffset = BitConverter.ToInt32(input, 4);
lumpLength = BitConverter.ToInt32(input, 8);
lumpIdent = BitConverter.ToInt32(input, 12);
// TODO: This is awful. Let's rework the enum to have internal ways to check engine forks.
} else if (version == MapType.Source17 ||
version == MapType.Source18 ||
version == MapType.Source19 ||
version == MapType.Source20 ||
version == MapType.Source21 ||
version == MapType.Source22 ||
version == MapType.Source23 ||
version == MapType.Source27 ||
version == MapType.Vindictus ||
version == MapType.DMoMaM ||
version == MapType.TacticalInterventionEncrypted) {
lumpOffset = BitConverter.ToInt32(input, 0);
lumpLength = BitConverter.ToInt32(input, 4);
lumpVersion = BitConverter.ToInt32(input, 8);
lumpIdent = BitConverter.ToInt32(input, 12);
} else if (version == MapType.CoD || version == MapType.CoD2) {
lumpLength = BitConverter.ToInt32(input, 0);
lumpOffset = BitConverter.ToInt32(input, 4);
} else {
lumpOffset = BitConverter.ToInt32(input, 0);
lumpLength = BitConverter.ToInt32(input, 4);
}
/*if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(lumpLength);
Array.Reverse(bytes);
lumpLength = BitConverter.ToInt32(bytes, 0);
bytes = BitConverter.GetBytes(lumpOffset);
Array.Reverse(bytes);
lumpOffset = BitConverter.ToInt32(bytes, 0);
}*/
return new LumpInfo() {
offset = lumpOffset,
length = lumpLength,
version = lumpVersion,
ident = lumpIdent
};
}
/// <summary>
/// Reads the lump in the BSP file using the information in "<paramref name="info"/>".
/// </summary>
/// <param name="info">The <see cref="LumpInfo"/> object representing the lump's information.</param>
/// <returns>A <c>byte</c> array containing the data from the file for the lump at the offset with the length from "<paramref name="info"/>".</returns>
public byte[] ReadLump(LumpInfo info) {
if (info.length == 0) { return new byte[0]; }
byte[] output;
if (info.lumpFile != null) {
using (FileStream fs = new FileStream(info.lumpFile.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader br = new BinaryReader(fs);
fs.Seek(info.offset, SeekOrigin.Begin);
output = br.ReadBytes(info.length);
br.Close();
return output;
}
}
using (FileStream stream = new FileStream(bspFile.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader binaryReader = new BinaryReader(stream);
stream.Seek(info.offset, SeekOrigin.Begin);
output = binaryReader.ReadBytes(info.length);
binaryReader.Close();
}
if (key.Length != 0) {
output = XorWithKeyStartingAtIndex(output, info.offset);
}
return output;
}
/// <summary>
/// Loads any lump files associated with the BSP.
/// </summary>
private void LoadLumpFiles() {
lumpFiles = new Dictionary<int, LumpInfo>();
// Scan the BSP's directory for lump files
DirectoryInfo dir = bspFile.Directory;
List<FileInfo> files = dir.GetFiles(bspFile.Name.Substring(0, bspFile.Name.Length - 4) + "_?_*.lmp").ToList();
// Sort the list by the number on the file
files.Sort((f1, f2) => {
int startIndex = bspFile.Name.Length - 1;
int f1EndIndex = f1.Name.LastIndexOf('.');
int f2EndIndex = f2.Name.LastIndexOf('.');
int f1Position = Int32.Parse(f1.Name.Substring(startIndex, f1EndIndex - startIndex));
int f2Position = Int32.Parse(f2.Name.Substring(startIndex, f2EndIndex - startIndex));
return f1Position - f2Position;
});
// Read the files in order. The last file in the list for a specific lump will replace that lump.
foreach (FileInfo file in files) {
using (FileStream fs = new FileStream(file.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader br = new BinaryReader(fs);
fs.Seek(0, SeekOrigin.Begin);
byte[] input = br.ReadBytes(20);
int offset = BitConverter.ToInt32(input, 0);
int lumpIndex = BitConverter.ToInt32(input, 4);
int version = BitConverter.ToInt32(input, 8);
int length = BitConverter.ToInt32(input, 12);
lumpFiles[lumpIndex] = new LumpInfo() {
offset = offset,
version = version,
length = length,
lumpFile = file
};
br.Close();
}
}
}
/// <summary>
/// Xors the <paramref name="data"/> <c>byte</c> array with the locally stored key <c>byte</c> array, starting at a certain <paramref name="index"/> in the key.
/// </summary>
/// <param name="data">The byte array to Xor.</param>
/// <param name="index">The index in the key byte array to start reading from.</param>
/// <returns>The input <c>byte</c> array Xored with the key <c>byte</c> array.</returns>
/// <exception cref="ArgumentNullException">The passed <paramref name="data"/> parameter was <c>null</c>.</exception>
private byte[] XorWithKeyStartingAtIndex(byte[] data, int index = 0) {
if (data == null) {
throw new ArgumentNullException();
}
if (key.Length == 0 || data.Length == 0) {
return data;
}
byte[] output = new byte[data.Length];
for (int i = 0; i < data.Length; i++) {
output[i] = (byte)(data[i] ^ key[(i + index) % key.Length]);
}
return output;
}
/// <summary>
/// Tries to get the <see cref="MapType"/> member most closely represented by the referenced file. If the file is
/// found to be big-endian, this will set <see cref="BSPReader.bigEndian"/> to <c>true</c>.
/// </summary>
/// <returns>The <see cref="MapType"/> of this BSP, <see cref="MapType.Undefined"/> if it could not be determined.</returns>
public MapType GetVersion() {
MapType ret = GetVersion(false);
if (ret == MapType.Undefined) {
ret = GetVersion(true);
if (ret != MapType.Undefined) {
_bigEndian = true;
}
}
return ret;
}
/// <summary>
/// Tries to get the <see cref="MapType"/> member most closely represented by the referenced file.
/// </summary>
/// <param name="bigEndian">Set to <c>true</c> to attempt reading the data in big-endian byte order.</param>
/// <returns>The <see cref="MapType"/> of this BSP, <see cref="MapType.Undefined"/> if it could not be determined.</returns>
private MapType GetVersion(bool bigEndian) {
MapType current = MapType.Undefined;
using (FileStream stream = new FileStream(bspFile.FullName, FileMode.Open, FileAccess.Read)) {
BinaryReader binaryReader = new BinaryReader(stream);
stream.Seek(0, SeekOrigin.Begin);
int data = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(data);
Array.Reverse(bytes);
data = BitConverter.ToInt32(bytes, 0);
}
if (data == 1347633737) {
// 1347633737 reads in ASCII as "IBSP"
// Versions: CoD, CoD2, CoD4, Quake 2, Daikatana, Quake 3 (RtCW), Soldier of Fortune
data = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(data);
Array.Reverse(bytes);
data = BitConverter.ToInt32(bytes, 0);
}
switch (data) {
case 4: {
current = MapType.CoD2;
break;
}
case 22: {
current = MapType.CoD4;
break;
}
case 38: {
current = MapType.Quake2;
break;
}
case 41: {
current = MapType.Daikatana;
break;
}
case 46: {
current = MapType.Quake3;
// This version number is both Quake 3 and Soldier of Fortune. Find out the length of the
// header, based on offsets.
for (int i = 0; i < 17; i++) {
stream.Seek((i + 1) * 8, SeekOrigin.Begin);
int temp = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(temp);
Array.Reverse(bytes);
temp = BitConverter.ToInt32(bytes, 0);
}
if (temp == 184) {
current = MapType.SoF;
break;
} else {
if (temp == 144) {
break;
}
}
}
break;
}
case 47: {
current = MapType.Quake3;
break;
}
case 59: {
current = MapType.CoD;
break;
}
}
} else {
if (data == 892416050) {
// 892416050 reads in ASCII as "2015," the game studio which developed MoHAA
current = MapType.MOHAA;
} else {
if (data == 1095516485) {
// 1095516485 reads in ASCII as "EALA," the ones who developed MoHAA Spearhead and Breakthrough
current = MapType.MOHAA;
} else {
if (data == 1347633750) {
// 1347633750 reads in ASCII as "VBSP." Indicates Source engine.
// Some source games handle this as 2 shorts.
// TODO: Big endian?
// Formats: Source 17-23 and 27, DMoMaM, Vindictus
data = (int)binaryReader.ReadUInt16();
switch (data) {
case 17: {
current = MapType.Source17;
break;
}
case 18: {
current = MapType.Source18;
break;
}
case 19: {
current = MapType.Source19;
break;
}
case 20: {
int version2 = (int)binaryReader.ReadUInt16();
if (version2 == 4) {
// TODO: This doesn't necessarily mean the whole map should be read as DMoMaM.
current = MapType.DMoMaM;
} else {
// TODO: Vindictus? Before I was determining these by looking at the Game Lump data, is there a better way?
current = MapType.Source20;
}
break;
}
case 21: {
current = MapType.Source21;
// Hack to determine if this is a L4D2 map. Read what would normally be the offset of
// a lump. If it is less than the header length it's probably not an offset, indicating L4D2.
stream.Seek(8, SeekOrigin.Begin);
int test = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(test);
Array.Reverse(bytes);
test = BitConverter.ToInt32(bytes, 0);
}
if (test < 1032) {
current = MapType.L4D2;
}
break;
}
case 22: {
current = MapType.Source22;
break;
}
case 23: {
current = MapType.Source23;
break;
}
case 27: {
current = MapType.Source27;
break;
}
}
} else {
if (data == 1347633746) {
// Reads in ASCII as "RBSP". Raven software's modification of Q3BSP, or Ritual's modification of Q2.
// Formats: Raven, SiN
current = MapType.Raven;
for (int i = 0; i < 17; i++) {
// Find out where the first lump starts, based on offsets.
stream.Seek((i + 1) * 8, SeekOrigin.Begin);
int temp = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(temp);
Array.Reverse(bytes);
temp = BitConverter.ToInt32(bytes, 0);
}
if (temp == 168) {
current = MapType.SiN;
break;
} else {
if (temp == 152) {
break;
}
}
}
} else {
if (data == 556942917) {
// "EF2!"
current = MapType.STEF2;
} else {
if (data == 1263223110) {
// "FAKK"
// Formats: STEF2 demo, Heavy Metal FAKK2 (American McGee's Alice)
data = binaryReader.ReadInt32();
if (bigEndian) {
byte[] bytes = BitConverter.GetBytes(data);
Array.Reverse(bytes);
data = BitConverter.ToInt32(bytes, 0);
}
switch (data) {
case 19: {
current = MapType.STEF2Demo;
break;
}
case 12:
case 42: {// American McGee's Alice
current = MapType.FAKK;
break;
}
}
} else {
switch (data) {
// Various numbers not representing a string
// Formats: HL1, Quake, Nightfire, or perhaps Tactical Intervention's encrypted format
case 29:
case 30: {
current = MapType.Quake;
break;
}
case 42: {
current = MapType.Nightfire;
break;
}
default: {
// Hack to get Tactical Intervention's encryption key. At offset 384, there are two unused lumps whose
// values in the header are always 0s. Grab these 32 bytes (256 bits) and see if they match an expected value.
stream.Seek(384, SeekOrigin.Begin);
key = binaryReader.ReadBytes(32);
stream.Seek(0, SeekOrigin.Begin);
data = BitConverter.ToInt32(XorWithKeyStartingAtIndex(binaryReader.ReadBytes(4)), 0);
if (data == 1347633750) {
current = MapType.TacticalInterventionEncrypted;
} else {
current = MapType.Undefined;
}
break;
}
}
}
}
}
}
}
}
}
binaryReader.Close();
}
return current;
}
}
}