#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. */ #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 things; private string sourcename; private DataLocation datalocation; private int sourcelumpindex; private int linenumber; private DehackedData dehackeddata; private Dictionary frames; private Dictionary texts; private Dictionary sprites; private Dictionary renamedsprites; private Dictionary newsprites; private string[] supportedpatchversions = { "19", "21", "2021" }; #endregion #region ================== Properties public List Things { get { return things; } } public Dictionary Texts { get { return texts; } } #endregion #region ================== Constructor public DehackedParser() { things = new List(); frames = new Dictionary(); texts = new Dictionary(); sprites = new Dictionary(); renamedsprites = new Dictionary(); newsprites = new Dictionary(); } #endregion #region ================== Parsing /// /// Parses a dehacked patch. /// /// The Dehacked patch text /// Dehacked data from the game configuration /// All sprite image names available in the resources /// public bool Parse(TextResourceData data, DehackedData dehackeddata, HashSet 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; } /// /// Returns a new line and increments the line number /// /// The read line private string GetLine() { linenumber++; string line = datareader.ReadLine(); if (line != null) return line.Trim(); else return null; } /// /// Logs a warning with the given message. /// /// The warning message 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); } /// /// Logs an error with the given message. /// /// The error message 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); } /// /// Get a key and value from a line in the format "key = value". /// /// The line to get the key and value from /// The key is written into this variable /// The value is writtin into this variable /// true if a key and value were retrieved, otherwise false 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; } /// /// This just keeps reading lines until a blank like is encountered. /// 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; } /// /// Parses the header of the Dehacked file. /// /// true if parsing the header was successful, otherwise false 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; } /// /// Parses a Dehacked thing /// /// The header of a thing definition block /// true if paring was successful, otherwise false private bool ParseThing(string line) { // Thing headers have the format "Thing ()". 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 = m.Groups[2].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; } /// /// Parses a Dehacked frame. /// /// The header of a frame definition block /// true if paring was successful, otherwise false private bool ParseFrame(string line) { // Frame headers have the format "Frame /// Parses a Dehacked text replacement /// /// The header of a text replacement block /// true if paring was successful, otherwise false private bool ParseText(string line) { // Text replacement headers have the format "Text " 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; } /// /// Parses a [SPRITES] block /// /// true if paring was successful, otherwise false 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; } /// /// Gets a dictionary of sprite replacements, with the key being the old sprite name, and the value being the new sprite name /// /// Dictionary of sprite replacements public Dictionary GetSpriteReplacements() { Dictionary replace = new Dictionary(); // 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 } }