public void ApplyFilePatch(string patchFile) { // are you sure bool continueFilePatch = true; Action <bool> setContinueFilePatch = newSetting => continueFilePatch = newSetting; YesNoPromptNotice(this, new RequestEventArgs <bool>("Directly patching the executable will not work with versions of the game that run though Steam\nAre you sure you want to do this?", setContinueFilePatch)); if (!continueFilePatch) { return; } string patchDir; if (Path.GetExtension(patchFile) == ".zip") { // clear amd (re)create temp folder patchDir = Directory.CreateDirectory($"{Path.GetTempPath()}\\tcrepainter_").FullName; Directory.Delete(patchDir, true); patchDir = Directory.CreateDirectory($"{Path.GetTempPath()}\\tcrepainter_").FullName; // extract to temp folder ZipFile.ExtractToDirectory(patchFile, patchDir); // navigate down directories to find patch.json if neccesary while (true) { if (File.Exists(patchDir + @"\patch.json")) { break; } if (Directory.GetDirectories(patchDir).Length > 0) { patchDir = Directory.GetDirectories(patchDir)[0]; } else { throw new Exception("Could not find patch.json file in the selected patch."); } } } else { patchDir = Path.GetDirectoryName(patchFile); } byte[] gameBytes = File.ReadAllBytes(PatchTarget); // get list of images from patchdir string[] images = Directory.GetFiles(patchDir, "*.png"); // deserialize patch.json string json = File.ReadAllText(patchDir + @"\patch.json"); JObject o = JObject.Parse(json); TowerclimbPatch patchData = o.ToObject <TowerclimbPatch>(); // break and explain to user if patch version does not match the exe they are trying to patch if (patchData.ImageCount != ImageCount) { InfoNotice(this, new NotificationEventArgs($"You are attempting to patch a game of version \"{GameVersion}\" ({ImageCount} images) with a patch made for version \"{patchData.GameVersion}\" ({patchData.ImageCount} images). This isn't going to work.")); return; } // start reading images into memory byte[][] imagesData = new byte[patchData.PngMap.Length][]; for (int i = 0; i < images.Length; i++) { if (bgw.IsBusy) { bgw.ReportProgress((i * 100) / images.Length, $"reading {images[i]}"); } // get id of image from filename to make sure they are array'd in the order they appear in the executable int idx = images[i].LastIndexOf('\\'); string imgNum = images[i].Substring(idx + 1, 5); if (!imgNum.All(Char.IsDigit)) { continue; } int id = int.Parse(imgNum); imagesData[id] = (File.ReadAllBytes(images[i])); } // write images to locations specified in exe by patch.json's PngMap bool ignoreSizeWarnings = false; for (int i = 0; i < patchData.PngMap.Length; i++) { if (bgw.IsBusy) { bgw.ReportProgress((i * 100) / images.Length, $"overwriting image {i}"); } // if index was never filled (nothing in patch folder for this image number) then skip if (imagesData[i] == null) { continue; } // if size is too big to patch back in, skip file and warn user unless they elect not to hear if (imagesData[i].Length > patchData.PngMap[i].Size) { if (!ignoreSizeWarnings) { Action <bool> setIgnoreSizeWarnings = (bool newSetting) => { ignoreSizeWarnings = newSetting; }; YesNoPromptNotice(this, new RequestEventArgs <bool>($"Image {i} (size {imagesData[i].Length}) is larger than the original file it is replacing (size {patchData.PngMap[i].Size}) and will not be patched in.\nClick no to ignore further warnings.", setIgnoreSizeWarnings)); } continue; } for (int j = 0; j < imagesData[i].Length; j++) { gameBytes[j + patchData.PngMap[i].Start] = imagesData[i][j]; } } // patch in all string things PatchableString[] totalPatchableStrings = patchData.StringMap .Concat(patchData.Fonts ?? new PatchableString[0]) .Concat(patchData.Menus != null ? (from menu in patchData.Menus select menu.Body) : new PatchableString[0]) .Concat(patchData.Menus != null ? (from menu in patchData.Menus where menu.Recipe != null select menu.Recipe) : new PatchableString[0]) .ToArray(); for (int i = 0; i < totalPatchableStrings.Length; i++) { PatchableString patchString = totalPatchableStrings[i]; List <byte> patchStringBytes = Encoding.Default.GetBytes(patchString.Override).Append <byte>(0x00).ToList(); for (int j = 0; j < patchString.Override.Length + 1; j++) { gameBytes[patchString.Start + j] = patchStringBytes[j]; } } File.WriteAllBytes(PatchTarget, gameBytes); InfoNotice(this, new NotificationEventArgs("Patched game successfully.")); }
/// <summary> /// Automatically scan for strings in exe using predetermined common patterns /// </summary> /// <param name="bytes"></param> /// <returns></returns> private void AutoScanStrings(byte[] bytes) { List <int> locations = new List <int>(); #region font scan bgw.ReportProgress(0, $"Scanning for fonts"); var fontOccurrences = new List <int>(); var fontLocations = new List <int>(); var locatedFonts = new List <PatchableString>(); string[] knownFonts = { "Times New Roman", "Courier", "Arial" }; for (int i = 0; i < knownFonts.Length; i++) { fontOccurrences.AddRange(bytes.Locate(Encoding.Default.GetBytes(knownFonts[i]))); fontOccurrences.AddRange(bytes.Locate(Encoding.Default.GetBytes(knownFonts[i].ToLower()))); } for (int i = 0; i < fontOccurrences.Count; i++) { // iterate fowrwards up to ending and add if not already found while (bytes[fontOccurrences[i] + 1] != 0x00) { fontOccurrences[i]++; } ; if (!fontLocations.Contains(fontOccurrences[i])) { fontLocations.Add(fontOccurrences[i]); } } locatedFonts = ValidateStringLocations(fontLocations.ToArray(), bytes).ToList(); // a consistent number of bytes before every font is often a string in the game that the font will be applied to // set this to the optional Context property so in the font browser the user can see what text they are likely changing the font for PatchableString[] fontContexts; for (int i = locatedFonts.Count - 1; i >= 0; i--) { if (!bytes.Skip(locatedFonts[i].Start - 3).Take(3).SequenceEqual(new byte[] { 0x00, 0x00, 0x00 })) { locatedFonts.RemoveAt(i); } } int[] contextLocations = (from locatedFont in locatedFonts select locatedFont.Start - 6).ToArray(); fontContexts = ValidateStringLocations(contextLocations, bytes, preserveIndexes: true); for (int i = 0; i < locatedFonts.Count; i++) { locatedFonts[i].Context = fontContexts[i]?.Original; } PatchableFonts = locatedFonts.ToArray(); #endregion #region menu scan bgw.ReportProgress(0, "Scanning for menus"); List <int> menuScanResults = new List <int>(); List <int> menuLocations = new List <int>(); menuScanResults.AddRange(bytes.Locate(new byte[] { 0x2F, 0x31, 0x00 })); menuScanResults.AddRange(bytes.Locate(new byte[] { 0x2F, 0x00, 0x26 })); foreach (int scanResult in menuScanResults) { // make sure the preceding byte is also a digit like it should be in the menu pattern and not some other thing if (bytes[scanResult - 1] >= 0x30 && bytes[scanResult - 1] <= 0x39) { menuLocations.Add(scanResult); } } for (int i = 0; i < menuLocations.Count; i++) { while (bytes[menuLocations[i] + 1] != 0x00) { menuLocations[i]++; } } ; PatchableString[] locatedMenus = ValidateStringLocations(menuLocations.ToArray(), bytes); int[] menuRecipeLocations = new int[locatedMenus.Length]; PatchableString[] menuRecipes = new PatchableString[locatedMenus.Length]; // scan for menu recipes: if they exist, they are always a consistent number of bytes prior for (int i = 0; i < locatedMenus.Length; i++) { menuRecipeLocations[i] = locatedMenus[i].Start - 0x5C; } menuRecipes = ValidateStringLocations(menuRecipeLocations, bytes, preserveIndexes: true); List <PatchableMenu> patchableMenus = new List <PatchableMenu>(); for (int i = 0; i < locatedMenus.Length; i++) { PatchableMenu entry = new PatchableMenu { Body = locatedMenus[i], Recipe = menuRecipes[i] }; if (bytes[entry.Body.End + 1] == 0x26) { entry.Body.Context = "concat"; } patchableMenus.Add(entry); } PatchableMenus = patchableMenus.ToArray(); #endregion #region string scan List <int> tagLocations = new List <int>(); // search for... // formatting tags string[] knownTags = { "#cb", "#cc", "#cg", "#cm", "#cr", "#cy", "#n", "#s0", "#s1", "#s2", "#s3", "#p1", "#p2", "#p3" }; // find occurences of predetermined formatting tags for (int i = 0; i < knownTags.Length; i++) { bgw.ReportProgress(((int)(((float)i / knownTags.Length) * 100)) / 8, $"Scanning for possible strings ({locations.Count + tagLocations.Count})"); tagLocations.AddRange(bytes.Locate(Encoding.Default.GetBytes(knownTags[i]))); } // deathcards (the first one in the list is just a space and all of then are separated by linebreaks) tagLocations.AddRange(bytes.Locate(new byte[] { 0x20, 0x0D, 0x0A })); // death adverbs (end in y, each followed by a line break) tagLocations.AddRange(bytes.Locate(new byte[] { 0x79, 0x0D, 0x0A })); // prepare eliminate duplicates for (int i = 0; i < tagLocations.Count; i++) { // iterate fowrwards up to ending and add if not already found while (bytes[tagLocations[i] + 1] != 0x00) { tagLocations[i]++; } ; if (!locations.Contains(tagLocations[i])) { locations.Add(tagLocations[i]); } } bgw.ReportProgress((1 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // look for strings ending with a space and 0x26 after their null byte (ones followed by 0x26 are always used concatenated with a string variable in-game) locations.AddRange(bytes.Locate(new byte[] { 0x20, 0x00, 0x26 })); bgw.ReportProgress((2 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // look for strings ending with a period and 0x26 after their null byte locations.AddRange(bytes.Locate(new byte[] { 0x2E, 0x00, 0x26 })); bgw.ReportProgress((3 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // ends with a period (most regular strings are followed by 0x09) locations.AddRange(bytes.Locate(new byte[] { 0x2E, 0x00, 0x09 })); bgw.ReportProgress((4 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // ends with closing parentheses locations.AddRange(bytes.Locate(new byte[] { 0x29, 0x00, 0x09 })); bgw.ReportProgress((5 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // ends with question mark locations.AddRange(bytes.Locate(new byte[] { 0x3F, 0x00, 0x09 })); bgw.ReportProgress((6 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // ends with exclamation point locations.AddRange(bytes.Locate(new byte[] { 0x21, 0x00, 0x09 })); bgw.ReportProgress((7 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // also get the contexts from the font locations locations.AddRange(contextLocations); bgw.ReportProgress((8 * 100) / 8, $"Scanning for possible strings ({locations.Count})"); // remove anything that appears in the found menus or found fonts var existingStrings = PatchableFonts.Concat(from menu in PatchableMenus select menu.Body).Concat(from menu in PatchableMenus where menu.Recipe != null select menu.Recipe); for (int i = locations.Count - 1; i >= 0; i--) { foreach (PatchableString existingString in existingStrings) { if (locations[i] > existingString.Start - 1 && locations[i] < existingString.End) { locations.RemoveAt(i); break; } } } PatchableString[] foundStrings = ValidateStringLocations(locations.ToArray(), bytes); PatchableStrings = foundStrings; #endregion }
/// <summary> /// Takes indexes of the ends of potential strings in a byte array, produces PatchableString objects for them if they are valid. /// </summary> /// <param name="locations"></param> /// <param name="bytes"></param> /// <param name="minimumLength" description="If a detected string has a length below this, it will be discarded and will not appear in the output"></param> /// <param name="preserveIndexes" description="If this is set to true, the function will output an array of identical length to the input array, each output matching the index of its corresponding input, unfilled ones left null."></param> /// <returns></returns> private PatchableString[] ValidateStringLocations(int[] locations, byte[] bytes, int minimumLength = 3, bool preserveIndexes = false) { List <PatchableString> foundStrings = new List <PatchableString>(); // get string bounds and the bytes residing inside for exporting as PatchableString objects for (int i = 0; i < locations.Length; i++) { bgw.ReportProgress((i * 100) / locations.Length, $"Verifying strings ({i}/{locations.Length})"); // set end to the string's terminating byte which is 1 byte forward int stringEnd = locations[i] + 1; // discard if string has already been found bool alreadyFound = false; for (int j = 0; j < foundStrings.Count; j++) { if ((foundStrings[j]?.End ?? 0) == stringEnd) { alreadyFound = true; break; } } if (alreadyFound) { goto _continue; } // iterate backwards up to the null byte preceding the beginning of the string int stringStart = stringEnd; while (bytes[stringStart - 1] != 0x00) { stringStart--; } ; // discard string if smaller than 3 (gets rid of unwanted UTF-16 stuff that accidentally gets picked up) if ((stringEnd - stringStart) < minimumLength) { goto _continue; } // discard string if it contains anything unprintable (gotta convert between encodings to check because the exe uses windows-1252 and C# uses utf-16) bool notPrintable = false; // convert encoding to utf16 and grab string from bytes string convertedString = Encoding.Unicode.GetString(Encoding.Convert(Encoding.Default, Encoding.Unicode, bytes, stringStart, stringEnd - stringStart)); foreach (char character in convertedString) { // if it's a control character and not a whitespace one, break and label string as not printable if (!(!Char.IsControl(character) || Char.IsWhiteSpace(character))) { notPrintable = true; break; } } // discard nonprintable string if (notPrintable) { goto _continue; } // build PatchableString var foundString = new PatchableString(stringStart, stringEnd, convertedString); foundStrings.Add(foundString); continue; _continue: if (preserveIndexes) { foundStrings.Add(null); } } return(foundStrings.ToArray()); }