2021-09-05 12:59:31 +00:00
#region = = = = = = = = = = = = = = = = = = Copyright ( c ) 2021 Boris Iwanski
/ *
* This program is free software : you can redistribute it and / or modify
*
* it under the terms of the GNU General Public License as published by
*
* the Free Software Foundation , either version 3 of the License , or
* ( at your option ) any later version .
*
* This program is distributed in the hope that it will be useful ,
* but WITHOUT ANY WARRANTY ; without even the implied warranty of
*
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
*
* GNU General Public License for more details .
*
* You should have received a copy of the GNU General Public License
* along with this program . If not , see < http : //www.gnu.org/licenses/>.
* /
#endregion
#region = = = = = = = = = = = = = = = = = = Namespaces
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Text ;
using System.Text.RegularExpressions ;
using CodeImp.DoomBuilder.Config ;
using CodeImp.DoomBuilder.Data ;
#endregion
namespace CodeImp.DoomBuilder.Dehacked
{
internal sealed class DehackedParser
{
#region = = = = = = = = = = = = = = = = = = Variables
private StreamReader datareader ;
private List < DehackedThing > things ;
private string sourcename ;
private DataLocation datalocation ;
private int sourcelumpindex ;
private int linenumber ;
private DehackedData dehackeddata ;
private Dictionary < int , DehackedFrame > frames ;
private Dictionary < string , string > texts ;
private Dictionary < int , string > sprites ;
private Dictionary < int , string > renamedsprites ;
private Dictionary < int , string > newsprites ;
2021-09-06 21:27:07 +00:00
private string [ ] supportedpatchversions = { "19" , "21" , "2021" } ;
2021-09-05 12:59:31 +00:00
#endregion
#region = = = = = = = = = = = = = = = = = = Properties
public List < DehackedThing > Things { get { return things ; } }
public Dictionary < string , string > Texts { get { return texts ; } }
#endregion
#region = = = = = = = = = = = = = = = = = = Constructor
public DehackedParser ( )
{
things = new List < DehackedThing > ( ) ;
frames = new Dictionary < int , DehackedFrame > ( ) ;
texts = new Dictionary < string , string > ( ) ;
sprites = new Dictionary < int , string > ( ) ;
renamedsprites = new Dictionary < int , string > ( ) ;
newsprites = new Dictionary < int , string > ( ) ;
}
#endregion
#region = = = = = = = = = = = = = = = = = = Parsing
/// <summary>
/// Parses a dehacked patch.
/// </summary>
/// <param name="data">The Dehacked patch text</param>
/// <param name="dehackeddata">Dehacked data from the game configuration</param>
/// <param name="availablesprites">All sprite image names available in the resources</param>
/// <returns></returns>
public bool Parse ( TextResourceData data , DehackedData dehackeddata , HashSet < string > availablesprites )
{
string line ;
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
sourcename = data . Filename ;
datalocation = data . SourceLocation ;
sourcelumpindex = data . LumpIndex ;
this . dehackeddata = dehackeddata ;
using ( datareader = new StreamReader ( data . Stream , Encoding . ASCII ) )
{
2021-11-28 17:02:35 +00:00
//if (!ParseHeader())
// return false;
2021-09-05 12:59:31 +00:00
while ( ! datareader . EndOfStream )
{
line = GetLine ( ) ;
2021-11-28 17:02:35 +00:00
string lowerline = line . ToLowerInvariant ( ) ;
2021-09-05 12:59:31 +00:00
// Skip blank lines and comments
if ( string . IsNullOrWhiteSpace ( line ) | | line . StartsWith ( "#" ) )
continue ;
2021-11-28 17:02:35 +00:00
if ( lowerline . StartsWith ( "thing" ) )
2021-09-05 12:59:31 +00:00
{
if ( ! ParseThing ( line ) )
return false ;
}
2021-11-28 17:02:35 +00:00
else if ( lowerline . StartsWith ( "frame" ) )
2021-09-05 12:59:31 +00:00
{
if ( ! ParseFrame ( line ) )
return false ;
}
2021-11-28 17:02:35 +00:00
else if ( lowerline . StartsWith ( "[sprites]" ) )
2021-09-05 12:59:31 +00:00
{
ParseSprites ( ) ;
}
2021-11-28 17:02:35 +00:00
else if ( lowerline . StartsWith ( "text" ) )
2021-09-05 12:59:31 +00:00
{
if ( ! ParseText ( line ) )
return false ;
}
2021-11-28 17:02:35 +00:00
else if ( lowerline . StartsWith ( "doom version" ) )
{
if ( ! ParseDoomVersion ( line ) )
return false ;
}
else if ( lowerline . StartsWith ( "patch format" ) )
{
if ( ! ParsePatchFormat ( line ) )
return false ;
}
2021-09-05 12:59:31 +00:00
else
{
// Just read over any block we don't know or care about
ParseDummy ( ) ;
}
}
}
// Process text replacements. This just renames sprites
foreach ( int key in dehackeddata . Sprites . Keys )
{
string sprite = dehackeddata . Sprites [ key ] ;
if ( texts . ContainsKey ( sprite ) )
sprites [ key ] = texts [ sprite ] ;
else
sprites [ key ] = sprite ;
}
// Replace or add new sprites. Apparently sprites in the [SPRITES] block have precedence over text replacements
foreach ( int key in renamedsprites . Keys )
sprites [ key ] = renamedsprites [ key ] ;
foreach ( int key in newsprites . Keys )
// Should anything be done when a new sprite redefines a sprite number that already exists?
sprites [ key ] = newsprites [ key ] ;
// Assign all frames that have not been redefined in the Dehacked patch to our dictionary of frames
foreach ( int key in dehackeddata . Frames . Keys )
{
if ( ! frames . ContainsKey ( key ) )
frames [ key ] = dehackeddata . Frames [ key ] ;
}
// Process the frames. Pass the base frame to the Process method, since we need to copy properties
// of the frames that are not defined in the Dehacked patch
foreach ( DehackedFrame f in frames . Values )
f . Process ( sprites , dehackeddata . Frames . ContainsKey ( f . Number ) ? dehackeddata . Frames [ f . Number ] : null ) ;
// Process things. Pass the base thing to the Process method, since we need to copy properties
// of the thing that are not defined in the Dehacked patch
foreach ( DehackedThing t in things )
t . Process ( frames , dehackeddata . BitMnemonics , dehackeddata . Things . ContainsKey ( t . Number ) ? dehackeddata . Things [ t . Number ] : null , availablesprites ) ;
return true ;
}
/// <summary>
/// Returns a new line and increments the line number
/// </summary>
/// <returns>The read line</returns>
private string GetLine ( )
{
linenumber + + ;
string line = datareader . ReadLine ( ) ;
if ( line ! = null )
2022-05-14 22:32:21 +00:00
{
line = line . Trim ( ) ;
// Editor key?
if ( line . StartsWith ( "$#" ) )
return line ;
// Cut everything from the line after a #, unless it's the "ID #" field, then cut everything after then next #
// This is technically against the (nowhere officially defined) DeHackEd specs, but of course people manually
// added comments at the end of lines anyway and got away with it
return Regex . Replace ( line , @"\s*(id\s+#)?([^#]*)(#[^$].+)?" , "$1$2" , RegexOptions . IgnoreCase ) . Trim ( ) ;
}
2022-05-11 21:28:50 +00:00
return null ;
2021-09-05 12:59:31 +00:00
}
/// <summary>
/// Logs a warning with the given message.
/// </summary>
/// <param name="message">The warning message</param>
private void LogWarning ( string message )
{
string errsource = Path . Combine ( datalocation . GetDisplayName ( ) , sourcename ) ;
if ( sourcelumpindex ! = - 1 ) errsource + = ":" + sourcelumpindex ;
message = "Dehacked warning in \"" + errsource + "\" line " + linenumber + ". " + message + "." ;
2021-12-10 17:13:16 +00:00
TextResourceErrorItem error = new TextResourceErrorItem ( ErrorType . Warning , ScriptType . UNKNOWN , datalocation , sourcename , sourcelumpindex , linenumber , message ) ;
2021-09-05 12:59:31 +00:00
General . ErrorLogger . Add ( error ) ;
}
/// <summary>
/// Logs an error with the given message.
/// </summary>
/// <param name="message">The error message</param>
private void LogError ( string message )
{
string errsource = Path . Combine ( datalocation . GetDisplayName ( ) , sourcename ) ;
if ( sourcelumpindex ! = - 1 ) errsource + = ":" + sourcelumpindex ;
message = "Dehacked error in \"" + errsource + "\" line " + linenumber + ". " + message + "." ;
2021-12-10 17:13:16 +00:00
TextResourceErrorItem error = new TextResourceErrorItem ( ErrorType . Error , ScriptType . UNKNOWN , datalocation , sourcename , sourcelumpindex , linenumber , message ) ;
2021-09-05 12:59:31 +00:00
General . ErrorLogger . Add ( error ) ;
}
/// <summary>
/// Get a key and value from a line in the format "key = value".
/// </summary>
/// <param name="line">The line to get the key and value from</param>
/// <param name="key">The key is written into this variable</param>
/// <param name="value">The value is writtin into this variable</param>
/// <returns>true if a key and value were retrieved, otherwise false</returns>
private bool GetKeyValueFromLine ( string line , out string key , out string value )
{
key = string . Empty ;
value = string . Empty ;
if ( ! line . Contains ( '=' ) )
{
LogError ( "Expected '=' in line, but it didn't contain one." ) ;
return false ;
}
string [ ] parts = line . Split ( '=' ) ;
key = parts [ 0 ] . Trim ( ) . ToLowerInvariant ( ) ;
value = parts [ 1 ] . Trim ( ) ;
return true ;
}
/// <summary>
/// This just keeps reading lines until a blank like is encountered.
/// </summary>
private void ParseDummy ( )
{
string line ;
while ( true )
{
line = GetLine ( ) ;
if ( string . IsNullOrWhiteSpace ( line ) ) break ;
if ( line . StartsWith ( "#" ) ) continue ;
}
}
2021-11-28 17:02:35 +00:00
private bool ParseDoomVersion ( string line )
{
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
// We expect the "Doom version = xxx" string
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
if ( fieldkey ! = "doom version" )
{
LogError ( "Expected 'Doom version', but got '" + fieldkey + "'." ) ;
return false ;
}
else if ( ! supportedpatchversions . Contains ( fieldvalue ) )
LogWarning ( "Unexpected Doom version. Expected one of " + string . Join ( ", " , supportedpatchversions ) + ", got " + fieldvalue + ". Parsing might not work correctly" ) ;
return true ;
}
private bool ParsePatchFormat ( string line )
{
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
// We expect the "Patch format = xxx" string
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
if ( fieldkey ! = "patch format" )
{
LogError ( "Expected 'Patch format', but got '" + fieldkey + "'." ) ;
return false ;
}
else if ( fieldvalue ! = "6" )
LogWarning ( "Unexpected patch format. Expected 6, got " + fieldvalue + ". Parsing might not work correctly" ) ;
return true ;
}
2021-09-05 12:59:31 +00:00
/// <summary>
/// Parses the header of the Dehacked file.
/// </summary>
/// <returns>true if parsing the header was successful, otherwise false</returns>
private bool ParseHeader ( )
{
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
// Read starting header
string line = GetLine ( ) ;
if ( line ! = "Patch File for DeHackEd v3.0" )
{
LogError ( "Did not find expected Dehacked file header." ) ;
return false ;
}
// Skip all empty lines or comments
do
{
line = GetLine ( ) ;
if ( line = = null )
{
LogError ( "File ended before header could be read." ) ;
return false ;
}
} while ( string . IsNullOrWhiteSpace ( line ) | | line . StartsWith ( "#" ) ) ;
// Now we expect the "Doom version = xxx" string
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
if ( fieldkey ! = "doom version" )
{
LogError ( "Expected 'Doom version', but got '" + fieldkey + "'." ) ;
return false ;
}
2021-09-06 21:27:07 +00:00
else if ( ! supportedpatchversions . Contains ( fieldvalue ) )
LogWarning ( "Unexpected Doom version. Expected one of " + string . Join ( ", " , supportedpatchversions ) + ", got " + fieldvalue + ". Parsing might not work correctly" ) ;
2021-09-05 12:59:31 +00:00
// Skip all empty lines or comments
do
{
line = GetLine ( ) ;
if ( line = = null )
{
LogError ( "File ended before header could be read." ) ;
return false ;
}
} while ( string . IsNullOrWhiteSpace ( line ) | | line . StartsWith ( "#" ) ) ;
// Now we expect the "Patch format = xxx" string
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
if ( fieldkey ! = "patch format" )
{
LogError ( "Expected 'Patch format', but got '" + fieldkey + "'." ) ;
return false ;
}
else if ( fieldvalue ! = "6" )
2021-09-06 21:27:07 +00:00
LogWarning ( "Unexpected patch format. Expected 6, got " + fieldvalue + ". Parsing might not work correctly" ) ;
2021-09-05 12:59:31 +00:00
return true ;
}
/// <summary>
/// Parses a Dehacked thing
/// </summary>
/// <param name="line">The header of a thing definition block</param>
/// <returns>true if paring was successful, otherwise false</returns>
private bool ParseThing ( string line )
{
// Thing headers have the format "Thing <thingnumber> (<thingname>)". Note that "thingnumber" is not the
// DoomEdNum, but the Dehacked thing number
2022-01-27 23:59:48 +00:00
Regex re = new Regex ( @"thing\s+(\d+)(\s+\((.+)\))?" , RegexOptions . IgnoreCase ) ;
2021-09-05 12:59:31 +00:00
Match m = re . Match ( line ) ;
if ( ! m . Success )
{
LogError ( "Found thing definition, but thing header seems to be wrong." ) ;
return false ;
}
int dehthingnumber = int . Parse ( m . Groups [ 1 ] . Value ) ;
2022-01-27 23:59:48 +00:00
string dehthingname = string . IsNullOrWhiteSpace ( m . Groups [ 3 ] . Value ) ? "<DeHackEd thing " + dehthingnumber + ">" : m . Groups [ 3 ] . Value ;
2021-09-05 12:59:31 +00:00
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
DehackedThing thing = new DehackedThing ( dehthingnumber , dehthingname ) ;
things . Add ( thing ) ;
while ( true )
{
line = GetLine ( ) ;
2021-09-11 20:11:07 +00:00
if ( string . IsNullOrWhiteSpace ( line ) )
break ;
else if ( line . StartsWith ( "#$" ) )
line = line . Substring ( 1 ) ;
else if ( line . StartsWith ( "#" ) ) continue ;
2021-09-05 12:59:31 +00:00
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
thing . Props [ fieldkey ] = fieldvalue ;
}
return true ;
}
/// <summary>
/// Parses a Dehacked frame.
/// </summary>
/// <param name="line">The header of a frame definition block</param>
/// <returns>true if paring was successful, otherwise false</returns>
private bool ParseFrame ( string line )
{
// Frame headers have the format "Frame <framenumber"
Regex re = new Regex ( @"frame\s+(\d+)" , RegexOptions . IgnoreCase ) ;
Match m = re . Match ( line ) ;
if ( ! m . Success )
{
LogError ( "Found frame definition, but frame header seems to be wrong." ) ;
return false ;
}
int framenumber = int . Parse ( m . Groups [ 1 ] . Value ) ;
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
DehackedFrame frame = new DehackedFrame ( framenumber ) ;
frames [ framenumber ] = frame ;
while ( true )
{
line = GetLine ( ) ;
if ( string . IsNullOrWhiteSpace ( line ) ) break ;
if ( line . StartsWith ( "#" ) ) continue ;
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
frame . Props [ fieldkey ] = fieldvalue ;
}
return true ;
}
/// <summary>
/// Parses a Dehacked text replacement
/// </summary>
/// <param name="line">The header of a text replacement block</param>
/// <returns>true if paring was successful, otherwise false</returns>
private bool ParseText ( string line )
{
// Text replacement headers have the format "Text <originallength> <newlength>"
Regex re = new Regex ( @"text\s+(\d+)\s+(\d+)" , RegexOptions . IgnoreCase ) ;
Match m = re . Match ( line ) ;
if ( ! m . Success )
{
LogError ( "Found text replacement definition, but text replacement header seems to be wrong." ) ;
return false ;
}
int textreplaceoldcount = int . Parse ( m . Groups [ 1 ] . Value ) ;
int textreplacenewcount = int . Parse ( m . Groups [ 2 ] . Value ) ;
// Read the old text character by character
StringBuilder oldtext = new StringBuilder ( textreplaceoldcount ) ;
while ( textreplaceoldcount > 0 )
{
2021-12-23 11:48:04 +00:00
// Sanity check for malformed patches, for example in dbimpact.wad (see https://github.com/jewalky/UltimateDoomBuilder/issues/673)
if ( datareader . EndOfStream )
{
LogError ( "Reached enexpected end of file when " + textreplaceoldcount + ( textreplaceoldcount = = 1 ? " more character was" : " more characters were" ) + " expected" ) ;
return false ;
}
2021-09-05 12:59:31 +00:00
int c = datareader . Read ( ) ;
// Dehacked patches use Windows style CRLF line endings, but text replacements
// actually only use LF, so we have to ignore the CR
if ( c = = '\r' ) continue ;
// Since we're not reading line by line we have to increment the line number ourselves
if ( c = = '\n' ) linenumber + + ;
oldtext . Append ( Convert . ToChar ( c ) ) ;
textreplaceoldcount - - ;
}
StringBuilder newtext = new StringBuilder ( ) ;
while ( textreplacenewcount > 0 )
{
2021-12-23 11:48:04 +00:00
// Sanity check for malformed patches, for example in dbimpact.wad (see https://github.com/jewalky/UltimateDoomBuilder/issues/673)
if ( datareader . EndOfStream )
{
LogWarning ( "Reached unexpected end of file when " + textreplacenewcount + ( textreplacenewcount = = 1 ? " more character was" : " more characters were" ) + " expected" ) ;
break ;
}
2021-09-05 12:59:31 +00:00
int c = datareader . Read ( ) ;
// Dehacked patches use Windows style CRLF line endings, but text replacements
// actually only use LF, so we have to ignore the CR
if ( c = = '\r' ) continue ;
// Since we're not reading line by line we have to increment the line number ourselves
if ( c = = '\n' ) linenumber + + ;
newtext . Append ( Convert . ToChar ( c ) ) ;
textreplacenewcount - - ;
}
// Sanity check. After reading old and new text there should be a CRLF
if ( ! datareader . EndOfStream & & datareader . Read ( ) ! = '\r' & & datareader . Read ( ) ! = '\n' )
{
LogError ( "Expected CRLF after text replacement, got something else." ) ;
return false ;
}
linenumber + + ;
texts [ oldtext . ToString ( ) ] = newtext . ToString ( ) ;
return true ;
}
/// <summary>
/// Parses a [SPRITES] block
/// </summary>
/// <returns>true if paring was successful, otherwise false</returns>
private bool ParseSprites ( )
{
string line ;
string fieldkey = string . Empty ;
string fieldvalue = string . Empty ;
while ( true )
{
line = GetLine ( ) ;
if ( string . IsNullOrWhiteSpace ( line ) ) break ;
if ( line . StartsWith ( "#" ) ) continue ;
if ( ! GetKeyValueFromLine ( line , out fieldkey , out fieldvalue ) )
return false ;
if ( fieldvalue . Length ! = 4 )
{
2021-09-06 21:27:07 +00:00
LogWarning ( "New sprite name has to be 4 characters long, but is " + fieldvalue . Length + " characters long. Skipping" ) ;
2021-09-05 12:59:31 +00:00
continue ;
}
int newspriteindex ;
if ( int . TryParse ( fieldkey , out newspriteindex ) )
{
// The key is a number, so it's a DSDhacked new sprite
newsprites [ newspriteindex ] = fieldvalue ;
}
else // Regular sprite replacement
{
if ( fieldkey . Length ! = 4 )
{
2021-09-06 21:27:07 +00:00
LogWarning ( "Old sprite name has to be 4 characters long, but is " + fieldkey . Length + " characters long. Skipping" ) ;
2021-09-05 12:59:31 +00:00
continue ;
}
// Find the sprite number of the original sprite and remember that we have to rename that
foreach ( int key in dehackeddata . Sprites . Keys )
{
if ( dehackeddata . Sprites [ key ] . ToLowerInvariant ( ) = = fieldkey )
{
renamedsprites [ key ] = fieldvalue ;
break ;
}
}
}
}
return true ;
}
/// <summary>
/// Gets a dictionary of sprite replacements, with the key being the old sprite name, and the value being the new sprite name
/// </summary>
/// <returns>Dictionary of sprite replacements</returns>
public Dictionary < string , string > GetSpriteReplacements ( )
{
Dictionary < string , string > replace = new Dictionary < string , string > ( ) ;
// Go through all text replacements
foreach ( string key in texts . Keys )
{
if ( key . Length ! = 4 | | texts [ key ] . Length ! = 4 ) continue ; // Sprites must be 4 characters long
replace [ key ] = texts [ key ] ;
}
// Go through all sprite and see if they have an replacement. Apparently they have higher precedence than text replacements
foreach ( int key in dehackeddata . Sprites . Keys )
{
if ( renamedsprites . ContainsKey ( key ) )
replace [ dehackeddata . Sprites [ key ] ] = renamedsprites [ key ] ;
}
return replace ;
}
#endregion
}
}