2014-10-22 17:48:30 +00:00
using System ;
2014-10-22 13:47:38 +00:00
using System.Collections.Generic ;
2014-10-22 17:07:03 +00:00
using System.Diagnostics ;
2014-10-22 13:47:38 +00:00
using System.IO ;
using System.Linq ;
using System.Text ;
2014-10-22 20:56:05 +00:00
using GarrysMod.AddonCreator.Hashing ;
2014-10-22 14:29:51 +00:00
using Newtonsoft.Json ;
2014-10-22 13:47:38 +00:00
2014-10-22 20:56:05 +00:00
namespace GarrysMod.AddonCreator.Addon
2014-10-22 13:47:38 +00:00
{
2014-10-22 20:56:05 +00:00
public class AddonFile
2014-10-22 13:47:38 +00:00
{
private const byte FormatVersion = 3 ;
private const uint AppID = 4000 ;
private const uint CompressionSignature = 0xbeefcace ;
2014-10-22 17:48:30 +00:00
private static readonly byte [ ] FormatIdent = Encoding . ASCII . GetBytes ( "GMAD" ) ;
/// <summary>
2014-10-22 20:56:05 +00:00
/// Initializes a new instance of <see cref="AddonFile" />
2014-10-22 17:48:30 +00:00
/// </summary>
2014-10-22 20:56:05 +00:00
public AddonFile ( )
2014-10-22 17:48:30 +00:00
{
Files = new Dictionary < string , AddonFileInfo > ( ) ;
RequiredContent = new List < string > ( ) ;
Version = 1 ;
}
/// <summary>
/// Returns the timestamp of when the addon was built. This data is retrieved from full imports and for new (unsaved)
/// addons this is 0.
/// </summary>
public ulong BuildTimestamp { get ; private set ; }
/// <summary>
/// The name of this addon.
/// </summary>
public string Title { get ; set ; }
/// <summary>
/// The author of this addon.
/// </summary>
public string Author { get ; set ; }
/// <summary>
/// A description of this addon.
/// </summary>
public string Description { get ; set ; }
/// <summary>
/// This addon's version.
/// </summary>
public int Version { get ; set ; }
/// <summary>
/// The files to include in the addon.
/// </summary>
public Dictionary < string , AddonFileInfo > Files { get ; private set ; }
/// <summary>
/// Currently unused.
/// </summary>
public ulong SteamID { get ; set ; }
/// <summary>
/// Content that needs to exist in order to run this addon.
/// </summary>
public List < string > RequiredContent { get ; private set ; }
2014-10-22 13:47:38 +00:00
/// <summary>
2014-10-22 17:48:30 +00:00
/// Imports a gmod addon into this instance.
2014-10-22 13:47:38 +00:00
/// </summary>
/// <param name="path">Path to a gmod addon file.</param>
/// <param name="withMetadata">Import all metadata (title, description, creator, timestamp, etc.) as well?</param>
public void Import ( string path , bool withMetadata = true )
{
2014-10-22 17:07:03 +00:00
var stream = File . OpenRead ( path ) ;
2014-10-22 13:47:38 +00:00
{
2014-10-23 00:49:01 +00:00
var sr = new BinaryReader ( stream , Encoding . GetEncoding ( "iso-8859-1" ) ) ;
2014-10-22 13:47:38 +00:00
// Check format header
if ( ! sr . ReadBytes ( 4 ) . SequenceEqual ( FormatIdent )
| | sr . ReadByte ( ) ! = FormatVersion )
throw new FormatException ( ) ;
2014-12-11 01:09:32 +00:00
#if DEBUG
2014-10-22 13:47:38 +00:00
// Check addon's CRC32 hash
2014-12-11 01:09:32 +00:00
// TODO: Garry's code actually calculates CRC32 hashes differently in edge cases. The code used for it is from zlib-1.1.3. See https://github.com/garrynewman/bootil/blob/master/src/3rdParty/smhasher/crc.cpp#L4.
2014-10-22 13:47:38 +00:00
{
2014-10-22 17:07:03 +00:00
Debug . WriteLine ( "Checking CRC32..." ) ;
2014-10-22 13:47:38 +00:00
var baseAddon = new byte [ stream . Length - sizeof ( int ) ] ;
var oldpos = stream . Position ;
stream . Position = 0 ;
stream . Read ( baseAddon , 0 , baseAddon . Length ) ;
var baseAddonHash = sr . ReadInt32 ( ) ;
2014-12-11 01:48:59 +00:00
var calcAddonHash = Crc32 . Compute ( baseAddon ) ;
2014-12-11 01:09:32 +00:00
Debug . WriteLine ( "\tCalculated hash: {0}; Wanted hash: {1}" , calcAddonHash , baseAddonHash ) ;
Debug . Assert ( calcAddonHash = = baseAddonHash , "CRC32 hash mismatch" ,
"Calculated CRC32 hash is different from the one saved within the addon. Causes could be a corrupted file or the edge case bug https://github.com/icedream/gmadsharp/issues/2." ) ;
2014-10-22 17:07:03 +00:00
stream . Position = oldpos ;
2014-10-22 13:47:38 +00:00
}
2014-12-11 01:09:32 +00:00
# endif
2014-10-22 13:47:38 +00:00
// Import metadata
var newSteamID = sr . ReadUInt64 ( ) ;
var newBuildTimestamp = sr . ReadUInt64 ( ) ;
var newRequiredContentLen = sr . ReadByte ( ) ;
2014-10-22 17:07:03 +00:00
var newTitle = sr . ReadString ( true ) ;
var newDescription = sr . ReadString ( true ) ;
var newAuthor = sr . ReadString ( true ) ;
var newVersion = sr . ReadInt32 ( ) ;
Debug . WriteLine ( "## Metadata ##" ) ;
Debug . WriteLine ( "Steam ID: {0}" , newSteamID ) ;
Debug . WriteLine ( "Build time: {0}" , newBuildTimestamp ) ;
Debug . WriteLine ( "Required content count: {0}" , newRequiredContentLen ) ;
Debug . Assert ( newSteamID = = 0 ) ;
Debug . Assert ( newRequiredContentLen = = 0 ) ;
2014-10-22 13:47:38 +00:00
for ( var b = 0 ; b < newRequiredContentLen ; b + + )
{
var value = sr . ReadString ( true ) ;
if ( withMetadata & & ! RequiredContent . Contains ( value ) )
RequiredContent . Add ( value ) ;
}
2014-10-22 17:07:03 +00:00
2014-10-22 13:47:38 +00:00
if ( withMetadata )
{
SteamID = newSteamID ;
BuildTimestamp = newBuildTimestamp ;
2014-10-22 17:07:03 +00:00
Title = newTitle ;
Description = newDescription ;
Author = newAuthor ;
Version = newVersion ;
2014-10-22 13:47:38 +00:00
}
2014-10-22 17:07:03 +00:00
Debug . WriteLine ( "" ) ;
2014-10-22 13:47:38 +00:00
// file list
2014-10-22 17:07:03 +00:00
Debug . WriteLine ( "## File list ##" ) ;
2014-10-22 13:47:38 +00:00
var newFilesList = new Dictionary < string , Tuple < long , int > > ( ) ;
2014-10-22 17:07:03 +00:00
var expectedFileId = 1 ;
2014-10-22 13:47:38 +00:00
do
{
var fileId = sr . ReadUInt32 ( ) ;
if ( fileId = = 0 )
break ; // end of list
// key, size, hash
var filePath = sr . ReadString ( true ) ;
var fileSize = sr . ReadInt64 ( ) ;
var fileHash = sr . ReadInt32 ( ) ;
2014-12-11 02:37:49 +00:00
Debug . WriteLine ( "\t#{2} : {0} ({1:0.0} kB)" , filePath , ( double ) fileSize / 1024 , fileId ) ;
2014-10-22 17:07:03 +00:00
Debug . Assert ( fileId = = expectedFileId ) ;
expectedFileId + + ;
2014-10-22 13:47:38 +00:00
// avoid duplicates
if ( newFilesList . ContainsKey ( filePath ) )
{
2014-10-22 17:48:30 +00:00
throw new IOException (
"Found duplicate file path in addon file. Contact the addon creator and tell him to build a new proper addon file." ) ;
2014-10-22 13:47:38 +00:00
}
newFilesList . Add ( filePath , new Tuple < long , int > ( fileSize , fileHash ) ) ;
} while ( true ) ;
2014-10-22 17:07:03 +00:00
Debug . WriteLine ( "" ) ;
2014-10-22 13:47:38 +00:00
2014-10-22 17:07:03 +00:00
Debug . WriteLine ( "## File import ##" ) ;
2014-10-22 13:47:38 +00:00
foreach ( var file in newFilesList )
{
var filePath = file . Key ;
var fileSize = file . Value . Item1 ;
var fileHash = file . Value . Item2 ;
2014-12-11 01:08:19 +00:00
var filePosition = sr . BaseStream . Position ;
2014-10-22 17:07:03 +00:00
2014-12-11 02:37:49 +00:00
Debug . WriteLine ( "Analyzing: {0} ({1:0.00} kB)" , filePath , ( double ) fileSize / 1024 ) ;
2014-10-22 17:07:03 +00:00
2014-10-22 13:47:38 +00:00
var fileContent = new byte [ fileSize ] ;
2014-10-22 17:48:30 +00:00
2014-10-22 13:47:38 +00:00
// long-compatible file reading
for ( long i = 0 ; i < fileSize ; i + = int . MaxValue )
{
2014-12-11 02:41:32 +00:00
sr
. ReadBytes ( ( int ) Math . Min ( int . MaxValue , fileSize ) )
. CopyTo ( fileContent , i ) ;
2014-10-22 13:47:38 +00:00
}
// CRC check for this file
2014-12-11 01:48:59 +00:00
var fileCalcHash = Crc32 . Compute ( fileContent ) ;
2014-12-11 01:09:32 +00:00
Debug . WriteLine ( "\t\tCalculated hash: {0}; Wanted hash: {1}" , fileCalcHash , fileHash ) ;
2014-10-22 13:47:38 +00:00
if ( fileCalcHash ! = fileHash )
{
throw new IOException ( "File " + filePath + " in addon file is corrupted (hash mismatch)" ) ;
}
2014-12-11 01:08:19 +00:00
Files . Add ( filePath , new SegmentedAddonFileInfo ( stream , filePosition , fileSize , fileHash ) ) ;
2014-10-22 13:47:38 +00:00
}
}
}
/// <summary>
2014-10-22 17:48:30 +00:00
/// Exports this addon into a GMA file.
2014-10-22 13:47:38 +00:00
/// </summary>
/// <param name="path">The output file path, should be pointing to a writable location ending with ".gma".</param>
public void Export ( string path )
{
2014-10-22 17:34:42 +00:00
// Enforce .gma extension
if ( ! path . EndsWith ( ".gma" , StringComparison . OrdinalIgnoreCase ) )
{
var pathSplit = path . Split ( '.' ) ;
pathSplit [ pathSplit . Length - 1 ] = "gma" ;
path = string . Join ( "." , pathSplit ) ;
}
2014-10-22 13:47:38 +00:00
// Checking for existing addon.json
if ( ! Files . ContainsKey ( "addon.json" ) )
{
throw new FileNotFoundException ( "Addon building requires a valid addon.json file." ) ;
}
2014-12-11 03:44:22 +00:00
var files = Files . ToDictionary ( i = > i . Key , i = > i . Value ) ;
if ( MinimizeLua )
files = files
// minimize lua code
. Select ( f = > f . Key . EndsWith ( ".lua" , StringComparison . OrdinalIgnoreCase )
? new KeyValuePair < string , AddonFileInfo > ( f . Key , new MinifiedLuaAddonFileInfo ( f . Value ) )
: f )
. ToDictionary ( i = > i . Key , i = > i . Value ) ;
2014-10-22 14:29:51 +00:00
// Check for errors and ignores in addon.json
2014-10-22 17:48:30 +00:00
var addonJson =
JsonConvert . DeserializeObject < AddonJson > ( Encoding . UTF8 . GetString ( Files [ "addon.json" ] . GetContents ( ) ) ) ;
2014-10-22 14:29:51 +00:00
addonJson . CheckForErrors ( ) ;
addonJson . RemoveIgnoredFiles ( ref files ) ;
2014-10-22 17:42:45 +00:00
// Extract data from addon.json
Title = addonJson . Title ;
2014-10-22 17:51:18 +00:00
Author = addonJson . Author ;
2014-10-22 17:42:45 +00:00
Description = string . IsNullOrEmpty ( addonJson . Description ) ? string . Empty : addonJson . Description ;
Version = addonJson . Version ;
2014-10-22 17:48:30 +00:00
2014-10-22 17:42:45 +00:00
// Create a stripped down version of addon.json for the output gma, it will replace the Description field
var newDescription = JsonConvert . SerializeObject ( new AddonJson
{
Description = Description ,
Tags = addonJson . Tags ,
Type = addonJson . Type
} ) ;
2014-10-22 17:07:03 +00:00
2014-10-22 14:29:51 +00:00
// Sort files
var resultingFiles = new SortedDictionary < string , AddonFileInfo > ( files ) ;
2014-10-22 17:42:45 +00:00
resultingFiles . Remove ( "addon.json" ) ;
2014-10-22 14:29:51 +00:00
// General whitelist
var blacklistedFiles = AddonWhitelist
. FindBlacklistedFiles ( resultingFiles . Select ( i = > i . Key ) )
. ToArray ( ) ;
if ( blacklistedFiles . Any ( ) )
{
throw new InvalidOperationException ( "Found files which aren't whitelisted. Remove or ignore those files before you retry packing your addon:"
2014-10-22 17:48:30 +00:00
+ Environment . NewLine + Environment . NewLine
+ string . Join ( Environment . NewLine , blacklistedFiles ) ) ;
2014-10-22 14:29:51 +00:00
}
2014-10-22 13:47:38 +00:00
using ( var stream = new MemoryStream ( ) )
{
// TODO: Standardized encoding - Garry should use standardized encoding, currently he uses Encoding.Default which is applocale-dependent...
2014-10-23 00:49:01 +00:00
var sw = new BinaryWriter ( stream , Encoding . GetEncoding ( "iso-8859-1" ) ) ;
2014-10-22 13:47:38 +00:00
// Format header
sw . Write ( FormatIdent ) ;
sw . Write ( FormatVersion ) ;
// Creator steam ID
sw . Write ( SteamID ) ;
// Build timestamp
2014-10-22 17:48:30 +00:00
sw . Write ( BuildTimestamp = ( ulong ) ( DateTime . UtcNow - new DateTime ( 1970 , 1 , 1 , 0 , 0 , 0 ) ) . TotalSeconds ) ;
2014-10-22 13:47:38 +00:00
// Required content
if ( RequiredContent . Count > byte . MaxValue )
{
2014-10-22 17:48:30 +00:00
throw new IndexOutOfRangeException ( "Required content count must not exceed " + byte . MaxValue +
" entries." ) ;
2014-10-22 13:47:38 +00:00
}
2014-10-22 17:48:30 +00:00
sw . Write ( ( byte ) RequiredContent . Count ) ;
2014-10-22 17:42:45 +00:00
foreach ( var content in RequiredContent )
2014-10-22 13:47:38 +00:00
{
sw . Write ( content , true ) ;
}
// Metadata
sw . Write ( Title , true ) ;
2014-10-22 17:42:45 +00:00
sw . Write ( newDescription , true ) ; // "Description" field which is actually a trimmed down addon.json
2014-10-22 13:47:38 +00:00
sw . Write ( Author , true ) ;
sw . Write ( Version ) ;
// File list
2014-10-23 00:28:05 +00:00
#if SUPPORT_BIG
2014-10-22 13:47:38 +00:00
if ( Files . Count > uint . MaxValue )
{
2014-10-22 17:48:30 +00:00
throw new IndexOutOfRangeException ( "Number of addon files must not exceed " + uint . MaxValue +
" elements." ) ;
2014-10-22 13:47:38 +00:00
}
2014-10-23 00:28:05 +00:00
# endif
2014-10-22 13:47:38 +00:00
uint fileNum = 0 ;
2014-10-22 14:29:51 +00:00
foreach ( var file in resultingFiles )
2014-10-22 13:47:38 +00:00
{
2014-12-11 02:37:49 +00:00
Console . WriteLine ( "Processing: {0} ({1:0.00} kB)" , file . Key , ( double ) file . Value . Size / 1024 ) ;
2014-10-22 13:47:38 +00:00
fileNum + + ;
sw . Write ( fileNum ) ;
sw . Write ( file . Key . ToLower ( ) , true ) ; // Path
sw . Write ( file . Value . Size ) ;
sw . Write ( file . Value . Crc32Hash ) ;
}
2014-10-22 17:48:30 +00:00
sw . Write ( ( uint ) 0 ) ; // End of file list
2014-10-22 13:47:38 +00:00
// File contents
2014-10-22 14:29:51 +00:00
foreach ( var file in resultingFiles )
2014-10-22 13:47:38 +00:00
{
if ( file . Value . Size = = 0 )
continue ;
2014-12-11 02:37:49 +00:00
Console . WriteLine ( "Packing: {0}" , file . Key ) ;
2014-10-22 13:47:38 +00:00
sw . Write ( file . Value . GetContents ( ) ) ;
}
// Addon CRC
2014-12-11 01:48:59 +00:00
var addonHash = Crc32 . Compute ( stream . ToArray ( ) ) ;
2014-10-22 13:47:38 +00:00
sw . Write ( addonHash ) ;
using ( var outfile = File . Create ( path ) )
{
2014-10-22 14:01:44 +00:00
stream . Position = 0 ;
2014-10-22 13:47:38 +00:00
stream . CopyTo ( outfile ) ;
}
}
}
2014-12-11 03:44:22 +00:00
/// <summary>
/// Indicates whether Lua files will have comments and unnecessary whitespace stripped out on export.
/// </summary>
public bool MinimizeLua { get ; set ; }
2014-10-22 13:47:38 +00:00
}
2014-10-22 17:48:30 +00:00
}