mirror of
https://git.do.srb2.org/STJr/UltimateZoneBuilder.git
synced 2024-11-30 15:41:30 +00:00
646 lines
19 KiB
C#
646 lines
19 KiB
C#
#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;
|
|
private string[] supportedpatchversions = { "19", "21", "2021" };
|
|
|
|
#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))
|
|
{
|
|
//if (!ParseHeader())
|
|
// return false;
|
|
|
|
while (!datareader.EndOfStream)
|
|
{
|
|
line = GetLine();
|
|
string lowerline = line.ToLowerInvariant();
|
|
|
|
// Skip blank lines and comments
|
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#"))
|
|
continue;
|
|
|
|
if (lowerline.StartsWith("thing"))
|
|
{
|
|
if (!ParseThing(line))
|
|
return false;
|
|
}
|
|
else if (lowerline.StartsWith("frame"))
|
|
{
|
|
if (!ParseFrame(line))
|
|
return false;
|
|
}
|
|
else if (lowerline.StartsWith("[sprites]"))
|
|
{
|
|
ParseSprites();
|
|
}
|
|
else if (lowerline.StartsWith("text"))
|
|
{
|
|
if (!ParseText(line))
|
|
return false;
|
|
}
|
|
else if(lowerline.StartsWith("doom version"))
|
|
{
|
|
if (!ParseDoomVersion(line))
|
|
return false;
|
|
}
|
|
else if(lowerline.StartsWith("patch format"))
|
|
{
|
|
if (!ParsePatchFormat(line))
|
|
return false;
|
|
}
|
|
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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <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 + ".";
|
|
|
|
TextResourceErrorItem error = new TextResourceErrorItem(ErrorType.Warning, ScriptType.UNKNOWN, datalocation, sourcename, sourcelumpindex, linenumber, message);
|
|
|
|
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 + ".";
|
|
|
|
TextResourceErrorItem error = new TextResourceErrorItem(ErrorType.Error, ScriptType.UNKNOWN, datalocation, sourcename, sourcelumpindex, linenumber, message);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
else if (!supportedpatchversions.Contains(fieldvalue))
|
|
LogWarning("Unexpected Doom version. Expected one of " + string.Join(", ", supportedpatchversions) + ", got " + fieldvalue + ". Parsing might not work correctly");
|
|
|
|
// 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")
|
|
LogWarning("Unexpected patch format. Expected 6, got " + fieldvalue + ". Parsing might not work correctly");
|
|
|
|
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
|
|
Regex re = new Regex(@"thing\s+(\d+)(\s+\((.+)\))?", RegexOptions.IgnoreCase);
|
|
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);
|
|
string dehthingname = string.IsNullOrWhiteSpace(m.Groups[3].Value) ? "<DeHackEd thing " + dehthingnumber + ">" : m.Groups[3].Value;
|
|
string fieldkey = string.Empty;
|
|
string fieldvalue = string.Empty;
|
|
|
|
DehackedThing thing = new DehackedThing(dehthingnumber, dehthingname);
|
|
things.Add(thing);
|
|
|
|
while(true)
|
|
{
|
|
line = GetLine();
|
|
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
break;
|
|
else if (line.StartsWith("#$"))
|
|
line = line.Substring(1);
|
|
else if (line.StartsWith("#")) continue;
|
|
|
|
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)
|
|
{
|
|
// 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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 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;
|
|
}
|
|
|
|
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)
|
|
{
|
|
LogWarning("New sprite name has to be 4 characters long, but is " + fieldvalue.Length + " characters long. Skipping");
|
|
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)
|
|
{
|
|
LogWarning("Old sprite name has to be 4 characters long, but is " + fieldkey.Length + " characters long. Skipping");
|
|
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
|
|
|
|
|
|
}
|
|
}
|