using DSPRE.Resources; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Windows.Forms; using static DSPRE.RomInfo; namespace DSPRE.ROMFiles { /// /// Class to store script file data in Pokémon NDS games /// public class ScriptFile : RomFile { // Cache for parsed plaintext scripts to avoid reparsing during search private static Dictionary plaintextCache = new Dictionary(); private static List<(int fileID, ushort commandID, long offset)> invalidCommandsEncountered = new List<(int, ushort, long)>(); private static bool suppressInvalidCommandErrors = false; /// /// Clears the plaintext script cache. Useful when closing ROM or reloading. /// public static void ClearPlaintextCache() { plaintextCache.Clear(); AppLogger.Info("Script: Plaintext cache cleared."); } public static List<(int fileID, ushort commandID, long offset)> GetInvalidCommands() { return new List<(int, ushort, long)>(invalidCommandsEncountered); } public static void ClearInvalidCommands() { invalidCommandsEncountered.Clear(); } /// /// Controls whether invalid command errors should show popups (false) or be collected silently (true) /// public static void SetSuppressInvalidCommandErrors(bool suppress) { suppressInvalidCommandErrors = suppress; } public enum ContainerTypes { Function, Action, Script }; public struct ContainerReference { public uint ID; public uint offsetInFile; } public List allScripts = new List(); public List allFunctions = new List(); public List allActions = new List(); public int fileID = -1; public bool isLevelScript = new bool(); public bool parseFailedDueToInvalidCommand = false; public bool hasNoScripts { get { return fileID == int.MaxValue; } } public static readonly char[] specialChars = { 'x', 'X', '#', '.', '_' }; public ScriptFile(Stream fs, bool readFunctions = true, bool readActions = true, int fileID = -1) { this.fileID = fileID; List scriptOffsets = new List(); List functionOffsets = new List(); List movementOffsets = new List(); using (BinaryReader br = new BinaryReader(fs)) { /* Read script offsets from the header */ isLevelScript = true; // Is Level Script as long as magic number FD13 doesn't exist try { while (true) { long headerPos = br.BaseStream.Position; uint checker = br.ReadUInt16(); br.BaseStream.Position -= 0x2; uint value = br.ReadUInt32(); if (value == 0 && scriptOffsets.Count == 0) { isLevelScript = true; break; } if (checker == 0xFD13) { br.BaseStream.Position -= 0x4; isLevelScript = false; break; } int offsetFromStart = (int)(value + br.BaseStream.Position); // Don't change order of addition scriptOffsets.Add(offsetFromStart); } } catch (EndOfStreamException) { if (!isLevelScript) { MessageBox.Show("Script File couldn't be read correctly.", "Unexpected EOF", MessageBoxButtons.OK, MessageBoxIcon.Error); } } if (isLevelScript) { return; } // Skip the 0xFD13 marker br.ReadUInt16(); /* Read scripts */ for (uint current = 0; current < scriptOffsets.Count; current++) { int index = scriptOffsets.FindIndex(x => x == scriptOffsets[(int)current]); // Check for UseScript if (index == current) { br.BaseStream.Position = scriptOffsets[(int)current]; List cmdList = new List(); bool endScript = new bool(); bool invalidCommandFound = false; while (!endScript) { ScriptCommand cmd = ReadCommand(br, ref functionOffsets, ref movementOffsets); if (cmd.cmdParams is null) { parseFailedDueToInvalidCommand = true; invalidCommandFound = true; endScript = true; } else { cmdList.Add(cmd); if (ScriptDatabase.endCodes.Contains((ushort)cmd.id)) { endScript = true; } } } allScripts.Add(new ScriptCommandContainer(current + 1, ContainerTypes.Script, commandList: cmdList)); if (invalidCommandFound) { AppLogger.Warn($"Script file {fileID}: Stopped parsing at Script {current + 1} due to invalid command. Remaining scripts, functions, and actions will not be loaded."); return; } } else { allScripts.Add(new ScriptCommandContainer(current + 1, ContainerTypes.Script, usedScriptID: index + 1)); } } /* Read functions */ if (readFunctions) { for (uint current = 0; current < functionOffsets.Count; current++) { br.BaseStream.Position = functionOffsets[(int)current]; int posInList = scriptOffsets.IndexOf(functionOffsets[(int)current]); // Check for UseScript if (posInList == -1) { List cmdList = new List(); bool endFunction = new bool(); bool invalidCommandFound = false; while (!endFunction) { ScriptCommand command = ReadCommand(br, ref functionOffsets, ref movementOffsets); if (command.cmdParams is null) { parseFailedDueToInvalidCommand = true; invalidCommandFound = true; endFunction = true; } else { cmdList.Add(command); if (ScriptDatabase.endCodes.Contains((ushort)command.id)) { endFunction = true; } } } allFunctions.Add(new ScriptCommandContainer(current + 1, ContainerTypes.Function, commandList: cmdList)); if (invalidCommandFound) { AppLogger.Warn($"Script file {fileID}: Stopped parsing at Function {current + 1} due to invalid command. Remaining functions and actions will not be loaded."); return; } } else { allFunctions.Add(new ScriptCommandContainer(current + 1, ContainerTypes.Function, usedScriptID: posInList + 1)); } } } if (readActions) { /* Read movements */ for (uint current = 0; current < movementOffsets.Count; current++) { br.BaseStream.Position = movementOffsets[(int)current]; List cmdList = new List(); bool endMovement = new bool(); while (!endMovement) { ushort id = br.ReadUInt16(); if (id == 0xFE) { endMovement = true; cmdList.Add(new ScriptAction(id, 0)); } else { cmdList.Add(new ScriptAction(id, br.ReadUInt16())); } } allActions.Add(new ScriptActionContainer(current + 1, commands: cmdList)); } } } } public ScriptFile(int fileID, bool readFunctions = true, bool readActions = true) { this.fileID = fileID; if (TryReadPlaintextIfNewer()) { return; } using (var fs = getFileStream(fileID)) { // Copy the logic from the Stream constructor var tempScript = new ScriptFile(fs, readFunctions, readActions, fileID); this.allScripts = tempScript.allScripts; this.allFunctions = tempScript.allFunctions; this.allActions = tempScript.allActions; this.isLevelScript = tempScript.isLevelScript; this.parseFailedDueToInvalidCommand = tempScript.parseFailedDueToInvalidCommand; } } static FileStream getFileStream(int fileID) { string path = Filesystem.GetScriptPath(fileID); return new FileStream(path, FileMode.OpenOrCreate); } /// /// Gets the file paths for both binary and plaintext versions of a script file /// public static (string binPath, string txtPath) GetFilePaths(int fileID) { string binPath = Filesystem.GetScriptPath(fileID); string expandedDir = Path.Combine(RomInfo.workDir, "expanded", "scripts"); string txtPath = Path.Combine(expandedDir, $"{fileID:D4}.script"); return (binPath, txtPath); } /// /// Tries to read the script file from plaintext ONLY if it's newer than the binary /// This is used during batch operations (like search) to respect external edits without slowdown /// Returns true if plaintext exists, is newer, and was successfully parsed /// Uses caching to avoid reparsing the same file multiple times /// private bool TryReadPlaintextIfNewer() { if (fileID < 0) return false; string txtPath = GetFilePaths(fileID).txtPath; string binPath = GetFilePaths(fileID).binPath; if (!File.Exists(txtPath)) { return false; } DateTime txtTimestamp = File.GetLastWriteTimeUtc(txtPath); if (File.Exists(binPath) && txtTimestamp <= File.GetLastWriteTimeUtc(binPath)) { return false; } if (plaintextCache.TryGetValue(txtPath, out var cached)) { if (cached.timestamp == txtTimestamp && cached.cached != null) { // Cache hit! Copy the parsed data this.allScripts = cached.cached.allScripts; this.allFunctions = cached.cached.allFunctions; this.allActions = cached.cached.allActions; this.isLevelScript = cached.cached.isLevelScript; return true; } } bool success = TryReadPlainTextFileCore(); if (success) { var cacheEntry = new ScriptFile(this.allScripts, this.allFunctions, this.allActions, fileID); cacheEntry.isLevelScript = this.isLevelScript; plaintextCache[txtPath] = (txtTimestamp, cacheEntry); } return success; } /// /// Core parsing logic for reading plaintext script files /// private bool TryReadPlainTextFileCore() { string txtPath = GetFilePaths(fileID).txtPath; try { string content = File.ReadAllText(txtPath); // Split content into sections const string SCRIPTS_HEADER = "//===== SCRIPTS =====//"; const string FUNCTIONS_HEADER = "//===== FUNCTIONS =====//"; const string ACTIONS_HEADER = "//===== ACTIONS =====//"; int scriptsStart = content.IndexOf(SCRIPTS_HEADER); int functionsStart = content.IndexOf(FUNCTIONS_HEADER); int actionsStart = content.IndexOf(ACTIONS_HEADER); if (scriptsStart == -1 || functionsStart == -1 || actionsStart == -1) { AppLogger.Error($"Script file {fileID:D4} ({txtPath}) has invalid format. Missing section headers. Binary will be re-extracted."); return false; } // Extract each section string scriptsSection = content.Substring( scriptsStart + SCRIPTS_HEADER.Length, functionsStart - scriptsStart - SCRIPTS_HEADER.Length ).Trim(); string functionsSection = content.Substring( functionsStart + FUNCTIONS_HEADER.Length, actionsStart - functionsStart - FUNCTIONS_HEADER.Length ).Trim(); string actionsSection = content.Substring( actionsStart + ACTIONS_HEADER.Length ).Trim(); // Parse each section using existing logic var scriptLines = scriptsSection.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); var functionLines = functionsSection.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); var actionLines = actionsSection.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); // Use the existing string-based constructor var tempScript = new ScriptFile(scriptLines, functionLines, actionLines, fileID); // Check if parsing failed (constructor returns with null lists) if (tempScript.allScripts == null) { AppLogger.Error($"Script file {fileID:D4} ({txtPath}) failed to parse. Binary will be re-extracted."); return false; } // Copy parsed data to this instance this.allScripts = tempScript.allScripts; this.allFunctions = tempScript.allFunctions; this.allActions = tempScript.allActions; this.isLevelScript = tempScript.isLevelScript; AppLogger.Info($"Script file {fileID:D4} loaded from plaintext: {txtPath}"); return true; } catch (Exception ex) { AppLogger.Error($"Script file {fileID:D4} ({txtPath}) - Exception: {ex.Message}. Binary will be re-extracted."); return false; } } /// /// Helper method to format script/function containers to plaintext (matches ScriptEditor.displayScriptFile logic) /// private static void AppendContainerList(StringBuilder content, List commandList, ScriptFile.ContainerTypes containerType) { for (int i = 0; i < commandList.Count; i++) { ScriptCommandContainer scriptCommandContainer = commandList[i]; /* Write header */ string header = containerType + " " + (i + 1); content.Append(header + ':' + Environment.NewLine); /* If current command is identical to another, print UseScript instead of commands */ if (scriptCommandContainer.usedScriptID < 0) { for (int j = 0; j < scriptCommandContainer.commands.Count; j++) { ScriptCommand command = scriptCommandContainer.commands[j]; if (!ScriptDatabase.endCodes.Contains(command.id)) { content.Append('\t'); } content.Append(command.name + Environment.NewLine); } } else { content.Append('\t' + "UseScript_#" + scriptCommandContainer.usedScriptID + Environment.NewLine); } content.AppendLine(); } } /// /// Helper method to format action containers to plaintext (matches ScriptEditor.displayScriptFileActions logic) /// private static void AppendActionList(StringBuilder content, List commandList, ScriptFile.ContainerTypes containerType) { for (int i = 0; i < commandList.Count; i++) { ScriptActionContainer currentCommand = commandList[i]; string header = containerType + " " + (i + 1); content.Append(header + ':' + Environment.NewLine); for (int j = 0; j < currentCommand.commands.Count; j++) { ScriptAction command = currentCommand.commands[j]; if (!ScriptDatabase.movementEndCodes.Contains(command.id)) { content.Append('\t'); } content.Append(command.name + Environment.NewLine); } content.AppendLine(); } } /// /// Writes the script file to plaintext .script format in expanded/scripts/ /// public void WritePlainTextFile() { if (fileID < 0) return; if (parseFailedDueToInvalidCommand) { AppLogger.Warn($"Script file {fileID:D4} was not fully parsed due to invalid commands. Skipping plaintext export to prevent data loss."); return; } string txtPath = GetFilePaths(fileID).txtPath; Directory.CreateDirectory(Path.GetDirectoryName(txtPath)); try { StringBuilder content = new StringBuilder(); // Add file header content.AppendLine("/*"); content.AppendLine(" * DSPRE Script File"); string romFileName = Path.GetFileNameWithoutExtension(RomInfo.projectName); string romFileNameClean = romFileName.EndsWith("_DSPRE_contents") ? romFileName.Substring(0, romFileName.Length - "_DSPRE_contents".Length) : romFileName; content.AppendLine(" * Rom ID: " + romFileNameClean); content.AppendLine(" * Game: " + RomInfo.gameFamily); content.AppendLine($" * File: {fileID:D4}"); content.AppendLine($" * Generated: {DateTime.Now}"); content.AppendLine(" */"); content.AppendLine(); // Add Scripts section content.AppendLine("//===== SCRIPTS =====//"); AppendContainerList(content, allScripts, ScriptFile.ContainerTypes.Script); // Add Functions section content.AppendLine("//===== FUNCTIONS =====//"); AppendContainerList(content, allFunctions, ScriptFile.ContainerTypes.Function); // Add Actions section content.AppendLine("//===== ACTIONS =====//"); AppendActionList(content, allActions, ScriptFile.ContainerTypes.Action); File.WriteAllText(txtPath, content.ToString()); AppLogger.Info($"Script file {fileID:D4} written to plaintext: {txtPath}"); } catch (Exception ex) { AppLogger.Error($"Failed to write plaintext script file {txtPath}: {ex.Message}"); } } /// /// Reloads script command database and reparses all scripts /// used when loading a custom database or replacing database through DB Manager /// /// Path to the JSON database file to load /// Optional callback to report progress during reparse /// List of remaining invalid commands after reload, empty if all successful public static List<(int fileID, ushort commandID, long offset)> ReloadDatabaseAndReparseAll(string databasePath, Action progressCallback = null) { ScriptDatabaseJsonLoader.InitializeFromJson(databasePath, RomInfo.gameVersion); RomInfo.ReloadScriptCommandDictionaries(); Resources.ScriptDatabase.InitializePokemonNames(); Resources.ScriptDatabase.InitializeItemNames(); Resources.ScriptDatabase.InitializeMoveNames(); Resources.ScriptDatabase.InitializeTrainerNames(); string expandedDir = Path.Combine(RomInfo.workDir, "expanded", "scripts"); if (Directory.Exists(expandedDir)) { Directory.Delete(expandedDir, true); } ClearInvalidCommands(); ExportAllScripts(suppressErrors: true, progressCallback: progressCallback); return GetInvalidCommands(); } /// /// Gets the hash of the current script command database to detect changes /// private static string GetDatabaseHash() { string baseFileName = Path.GetFileNameWithoutExtension(RomInfo.projectName); string romFileNameClean = baseFileName.EndsWith("_DSPRE_contents") ? baseFileName.Substring(0, baseFileName.Length - "_DSPRE_contents".Length) : baseFileName; string databasePath = Path.Combine(Program.DatabasePath, "edited_databases", $"{romFileNameClean}_scrcmd_database.json"); if (!File.Exists(databasePath)) return string.Empty; using (var md5 = System.Security.Cryptography.MD5.Create()) { using (var stream = File.OpenRead(databasePath)) { byte[] hash = md5.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } } } /// /// Exports all script files from the ROM to expanded/scripts/ on initial load /// This ensures the expanded directory is populated with plaintext versions /// /// If true, collects invalid command errors silently instead of showing popups /// Optional callback to report progress (current index, total count) /// True if export completed, false if cancelled or failed public static bool ExportAllScripts(bool suppressErrors = false, Action progressCallback = null) { string expandedDir = Path.Combine(RomInfo.workDir, "expanded", "scripts"); string dbHashMarkerPath = Path.Combine(expandedDir, ".database_hash"); if (Directory.Exists(expandedDir)) { var existingFiles = Directory.GetFiles(expandedDir, "*.script"); int scriptCount = Filesystem.GetScriptCount(); // If we have all script files, check if database has changed if (existingFiles.Length >= scriptCount) { string currentDbHash = GetDatabaseHash(); string lastDbHash = File.Exists(dbHashMarkerPath) ? File.ReadAllText(dbHashMarkerPath).Trim() : string.Empty; if (!string.IsNullOrEmpty(currentDbHash) && currentDbHash != lastDbHash) { AppLogger.Info($"Script: Database has changed since last export (hash mismatch). Re-exporting all scripts to sync with new database."); Directory.Delete(expandedDir, true); Directory.CreateDirectory(expandedDir); } else { AppLogger.Info($"Script: expanded/scripts already exists with {existingFiles.Length} files, skipping initial export."); return true; } } else { AppLogger.Info($"Script: expanded/scripts exists with only {existingFiles.Length}/{scriptCount} files. Re-exporting to fill in missing scripts."); } } else { Directory.CreateDirectory(expandedDir); } ClearInvalidCommands(); SetSuppressInvalidCommandErrors(suppressErrors); try { // Get count of script files int scriptCount = Filesystem.GetScriptCount(); int exportedCount = 0; AppLogger.Info($"Script: Beginning export of {scriptCount} script files to {expandedDir}..."); for (int i = 0; i < scriptCount; i++) { try { string txtPath = GetFilePaths(i).txtPath; if (File.Exists(txtPath)) { AppLogger.Debug($"Script {i:D4} plaintext already exists, skipping to preserve any edits."); progressCallback?.Invoke(i + 1, scriptCount); continue; } // Load from binary only (don't try to read plaintext since we're creating it) ScriptFile scriptFile = new ScriptFile(i, true, true); scriptFile.WritePlainTextFile(); exportedCount++; // Touch the binary file to make it "newer" than the plaintext // This ensures search performance isn't impacted (binary used unless plaintext is edited) string binPath = GetFilePaths(i).binPath; if (File.Exists(binPath)) { File.SetLastWriteTimeUtc(binPath, DateTime.UtcNow.AddSeconds(1)); } } catch (Exception ex) { AppLogger.Error($"Failed to export script {i:D4}: {ex.Message}"); } progressCallback?.Invoke(i + 1, scriptCount); } AppLogger.Info($"Script: Exported {exportedCount} of {scriptCount} script files to {expandedDir}"); string currentDbHash = GetDatabaseHash(); if (!string.IsNullOrEmpty(currentDbHash)) { File.WriteAllText(dbHashMarkerPath, currentDbHash); } SetSuppressInvalidCommandErrors(false); return true; } catch (Exception ex) { AppLogger.Error($"Failed to export scripts: {ex.Message}"); SetSuppressInvalidCommandErrors(false); return false; } } /// /// Scans expanded/scripts/ directory and rebuilds binary script files that are older than their plaintext versions /// Call this during ROM save, similar to TextArchive.BuildRequiredBins() /// public static bool BuildRequiredBins() { string expandedDir = Path.Combine(RomInfo.workDir, "expanded", "scripts"); if (!Directory.Exists(expandedDir)) { AppLogger.Info("Script: No expanded scripts directory found, skipping .bin rebuild."); return true; } var expandedScriptFiles = Directory.GetFiles(expandedDir, "*.script", SearchOption.AllDirectories); int newerBinCount = 0; int rebuiltCount = 0; for (int i = 0; i < expandedScriptFiles.Length; i++) { string expandedScriptFile = expandedScriptFiles[i]; string fileName = Path.GetFileNameWithoutExtension(expandedScriptFile); int scriptID; try { scriptID = int.Parse(fileName); } catch { AppLogger.Error($"Skipping invalid script file name: {fileName}"); continue; } string binPath = ScriptFile.GetFilePaths(scriptID).binPath; // Skip if .bin is newer than .script if (File.Exists(binPath) && File.GetLastWriteTimeUtc(binPath) > File.GetLastWriteTimeUtc(expandedScriptFile)) { newerBinCount++; continue; } try { var scriptFile = new ScriptFile(scriptID); scriptFile.SaveToFileDefaultDir(scriptID, false); rebuiltCount++; // Update .script last write time to prevent it being overwritten when reopening the ROM File.SetLastWriteTimeUtc(expandedScriptFile, DateTime.UtcNow); } catch (Exception ex) { AppLogger.Error($"Failed to rebuild script {scriptID:D4} from plaintext: {ex.Message}"); } } AppLogger.Info($"Script: {rebuiltCount} .bin files built from .script, {newerBinCount} .bin files skipped because they were newer than the .script"); return true; } public override string ToString() { string prefix = isLevelScript ? "Level " : ""; return $"{prefix}Script File " + this.fileID; } public ScriptFile(List scripts, List functions, List movements, int fileID = -1) { this.fileID = fileID; this.allScripts = scripts; this.allFunctions = functions; this.allActions = movements; this.isLevelScript = false; } public ScriptFile(IEnumerable scriptLines, IEnumerable functionLines, IEnumerable actionLines, int fileID = -1) { //TODO: give user the possibility to jump to/call a script //once it's done, this Predicate below will be the only one needed, since there will be no distinction between //a script and a function bool functionEndCondition(List<(int linenum, string text)> source, int x, ushort? id) { return source[x].text.TrimEnd().IgnoreCaseEquals(RomInfo.ScriptCommandNamesDict[0x0002]) //End || source[x].text.IndexOf(RomInfo.ScriptCommandNamesDict[0x0016] + ' ' + ContainerTypes.Function.ToString(), StringComparison.InvariantCultureIgnoreCase) >= 0 //Jump Function_# || source[x].text.TrimEnd().IgnoreCaseEquals(RomInfo.ScriptCommandNamesDict[0x001B]) || ScriptDatabase.endCodes.Contains(id); } //Return bool scriptEndCondition(List<(int linenum, string text)> source, int x, ushort? id) { return source[x].text.TrimEnd().IgnoreCaseEquals(RomInfo.ScriptCommandNamesDict[0x0002]) //End || source[x].text.IndexOf(RomInfo.ScriptCommandNamesDict[0x0016] + ' ' + ContainerTypes.Function.ToString()) >= 0 //Jump Function_# || ScriptDatabase.endCodes.Contains(id); } allScripts = ReadCommandsFromLines(scriptLines.ToList(), ContainerTypes.Script, scriptEndCondition); //Jump + whitespace if (allScripts is null) { return; } if (allScripts.Count <= 0) { this.fileID = int.MaxValue; return; } if (functionLines != null) { allFunctions = ReadCommandsFromLines(functionLines.ToList(), ContainerTypes.Function, functionEndCondition); //Jump + whitespace if (allFunctions is null) { return; } } if (actionLines != null) { allActions = ReadActionsFromLines(actionLines.ToList()); if (allActions is null) { return; } } this.fileID = fileID; } private ScriptCommand ReadCommand(BinaryReader dataReader, ref List functionOffsets, ref List actionOffsets) { ushort id = dataReader.ReadUInt16(); List parameterList = new List(); /* How to read parameters for different commands for DPPt*/ switch (RomInfo.gameFamily) { case GameFamilies.DP: case GameFamilies.Plat: switch (id) { case 0x16: //Jump case 0x1A: //Call ProcessRelativeJump(dataReader, ref parameterList, ref functionOffsets); break; case 0x17: //JumpIfObjID case 0x18: //JumpIfBgID case 0x19: //JumpIfPlayerDir case 0x1C: //JumpIf case 0x1D: //CallIf //in the case of JumpIf and CallIf, the first param is a comparisonOperator //for JumpIfPlayerDir it's a directionID //for JumpIfObjID, it's an EventID parameterList.Add(new byte[] { dataReader.ReadByte() }); ProcessRelativeJump(dataReader, ref parameterList, ref functionOffsets); break; case 0x5E: // Movement parameterList.Add(BitConverter.GetBytes(dataReader.ReadUInt16())); ProcessRelativeJump(dataReader, ref parameterList, ref actionOffsets); break; case 0x1CF: case 0x1D0: case 0x1D1: { byte parameter1 = dataReader.ReadByte(); parameterList.Add(new byte[] { parameter1 }); if (parameter1 == 0x2) { parameterList.Add(dataReader.ReadBytes(2)); //Read additional u16 if first param read is 2 } } break; case 0x21D: { ushort parameter1 = dataReader.ReadUInt16(); parameterList.Add(BitConverter.GetBytes(parameter1)); switch (parameter1) { case 0: case 1: case 2: case 3: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; case 4: case 5: parameterList.Add(dataReader.ReadBytes(2)); break; case 6: break; } } break; case 0x235: { short parameter1 = dataReader.ReadInt16(); parameterList.Add(BitConverter.GetBytes(parameter1)); switch (parameter1) { case 0x1: case 0x3: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; case 0x4: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; case 0x0: case 0x6: parameterList.Add(dataReader.ReadBytes(2)); break; default: break; } } break; case 0x23E: { short parameter1 = dataReader.ReadInt16(); parameterList.Add(BitConverter.GetBytes(parameter1)); switch (parameter1) { case 0x1: case 0x3: parameterList.Add(dataReader.ReadBytes(2)); break; case 0x5: case 0x6: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; default: break; } } break; case 0x2C4: { byte parameter1 = dataReader.ReadByte(); parameterList.Add(new byte[] { parameter1 }); if (parameter1 == 0 || parameter1 == 1) { parameterList.Add(dataReader.ReadBytes(2)); } } break; case 0x2C5: { if (RomInfo.gameVersion == GameVersions.Platinum) { parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); } else { goto default; } } break; case 0x2C6: case 0x2C9: case 0x2CA: case 0x2CD: if (RomInfo.gameVersion == GameVersions.Platinum) { break; } else { goto default; } case 0x2CF: if (RomInfo.gameVersion == GameVersions.Platinum) { parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); } else { goto default; } break; default: addParametersToList(ref parameterList, id, dataReader); break; } break; case GameFamilies.HGSS: switch (id) { case 0x16: //Jump case 0x1A: //Call ProcessRelativeJump(dataReader, ref parameterList, ref functionOffsets); break; case 0x17: //JumpIfObjID case 0x18: //JumpIfBgID case 0x19: //JumpIfPlayerDir case 0x1C: //JumpIf case 0x1D: //CallIf parameterList.Add(new byte[] { dataReader.ReadByte() }); //in the case of JumpIf and CallIf, the first param is a comparisonOperator ProcessRelativeJump(dataReader, ref parameterList, ref functionOffsets); break; case 0x5E: // Movement parameterList.Add(BitConverter.GetBytes(dataReader.ReadUInt16())); //in the case of Movement, the first param is an overworld ID ProcessRelativeJump(dataReader, ref parameterList, ref actionOffsets); break; case 0x190: case 0x191: case 0x192: { byte parameter1 = dataReader.ReadByte(); parameterList.Add(new byte[] { parameter1 }); if (parameter1 == 0x2) { parameterList.Add(dataReader.ReadBytes(2)); } } break; case 0x1D1: // Number of parameters differ depending on the first parameter value { short parameter1 = dataReader.ReadInt16(); parameterList.Add(BitConverter.GetBytes(parameter1)); switch (parameter1) { case 0x0: case 0x1: case 0x2: case 0x3: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; case 0x4: case 0x5: parameterList.Add(dataReader.ReadBytes(2)); break; case 0x6: break; case 0x7: parameterList.Add(dataReader.ReadBytes(2)); break; default: break; } } break; case 0x1E9: // Number of parameters differ depending on the first parameter value { short parameter1 = dataReader.ReadInt16(); parameterList.Add(BitConverter.GetBytes(parameter1)); switch (parameter1) { case 0x0: break; case 0x1: case 0x2: case 0x3: parameterList.Add(dataReader.ReadBytes(2)); break; case 0x4: break; case 0x5: case 0x6: parameterList.Add(dataReader.ReadBytes(2)); parameterList.Add(dataReader.ReadBytes(2)); break; case 0x7: case 0x8: break; default: break; } } break; default: addParametersToList(ref parameterList, id, dataReader); break; } break; } return new ScriptCommand(id, parameterList); } private void ProcessRelativeJump(BinaryReader dataReader, ref List parameterList, ref List offsetsList) { int relativeOffset = dataReader.ReadInt32(); int offsetFromScriptFileStart = (int)(relativeOffset + dataReader.BaseStream.Position); if (!offsetsList.Contains(offsetFromScriptFileStart)) { offsetsList.Add(offsetFromScriptFileStart); } int functionNumber = offsetsList.IndexOf(offsetFromScriptFileStart); if (functionNumber < 0) { throw new InvalidOperationException(); } parameterList.Add(BitConverter.GetBytes(functionNumber + 1)); } private void addParametersToList(ref List parameterList, ushort id, BinaryReader dataReader) { AppLogger.Debug("Loaded command id: " + id.ToString("X4")); try { foreach (int bytesToRead in RomInfo.ScriptCommandParametersDict[id]) { parameterList.Add(dataReader.ReadBytes(bytesToRead)); } } catch (NullReferenceException) { long offset = dataReader.BaseStream.Position; invalidCommandsEncountered.Add((fileID, id, offset)); if (!suppressInvalidCommandErrors) { MessageBox.Show("Script command " + id + "can't be handled for now." + Environment.NewLine + "Reference offset 0x" + offset.ToString("X"), "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } parameterList = null; return; } catch { long offset = dataReader.BaseStream.Position; invalidCommandsEncountered.Add((fileID, id, offset)); if (!suppressInvalidCommandErrors) { MessageBox.Show("Error: ID Read - " + id + Environment.NewLine + "Reference offset 0x" + offset.ToString("X"), "Unrecognized script command", MessageBoxButtons.OK, MessageBoxIcon.Error); } parameterList = null; return; } } private void AddReference(ref List references, ushort commandID, List parameterList, int pos, ScriptCommandContainer cont) { if (ScriptDatabase.commandsWithRelativeJump.TryGetValue(commandID, out int parameterWithRelativeJump)) { uint invokedID = BitConverter.ToUInt32(parameterList[parameterWithRelativeJump], 0); // Jump, Call if (commandID == 0x005E) references.Add(new ScriptReference(cont.containerType, cont.manualUserID, ContainerTypes.Action, invokedID, pos - 4)); else { references.Add(new ScriptReference(cont.containerType, cont.manualUserID, ContainerTypes.Function, invokedID, pos - 4)); } } } private List<(int linenum, string text)> PreprocessLines(List linelist) { List<(int linenum, string text)> lineSource = new List<(int linenum, string text)>(); bool inBlockComment = false; for (int l = 0; l < linelist.Count; l++) { string line = linelist[l]; // handle block comments first if (inBlockComment) { int endComment = line.IndexOf("*/"); if (endComment >= 0) { line = line.Substring(endComment + 2); inBlockComment = false; } else { continue; } } while (!inBlockComment && line.Contains("/*")) { int startComment = line.IndexOf("/*"); int endComment = line.IndexOf("*/", startComment); if (endComment >= 0) { line = line.Substring(0, startComment) + line.Substring(endComment + 2); } else { line = line.Substring(0, startComment); inBlockComment = true; } } int inlineComment = line.IndexOf("//"); if (inlineComment >= 0) { line = line.Substring(0, inlineComment); } line = line.Trim(); if (!string.IsNullOrWhiteSpace(line)) { lineSource.Add((l, line)); } } return lineSource; } private List ReadCommandsFromLines(List linelist, ContainerTypes containerType, Func, int, ushort?, bool> endConditions) { List<(int linenum, string text)> lineSource = PreprocessLines(linelist); List ls = new List(); int i = 0; try { uint scriptNumber = 0; while (i < lineSource.Count) { if (scriptNumber == 0) { int positionOfScriptNumber; int positionOfScriptKeyword = lineSource[i].text.IndexOf(containerType.ToString(), StringComparison.InvariantCultureIgnoreCase); if (positionOfScriptKeyword > 0) { MessageBox.Show("Unrecognized container keyword: \"" + lineSource[i] + '"', "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } else if (positionOfScriptKeyword < 0) { i++; continue; } else { if ((positionOfScriptNumber = lineSource[i].text.IndexOfFirstNumber()) < positionOfScriptKeyword) { MessageBox.Show("Unspecified Script/Function label.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } } scriptNumber = uint.Parse(lineSource[i++].text.Substring(positionOfScriptNumber).Split()[0].Replace(":", "")); } if (lineSource[i].text.IndexOf("UseScript", StringComparison.InvariantCultureIgnoreCase) >= 0) { int useScriptNumber = short.Parse(lineSource[i].text.Substring(1 + lineSource[i].text.IndexOf('#'))); ls.Add(new ScriptCommandContainer(scriptNumber, containerType, useScriptNumber)); i++; } else { /* Read script commands */ List cmdList = new List(); ScriptCommand lastRead; do { lastRead = new ScriptCommand(lineSource[i].text, lineSource[i].linenum + 1); if (lastRead.id is null) { return null; } cmdList.Add(lastRead); } while (!endConditions(lineSource, i++, lastRead.id)); ls.Add(new ScriptCommandContainer(scriptNumber, containerType, commandList: cmdList)); } scriptNumber = 0; } } catch (ArgumentOutOfRangeException) { MessageBox.Show($"Unexpectedly reached end of lines.\n\n" + $"Last line index: {lineSource[i].linenum}.\n" + $"Managed to parse {ls.Count} Command Containers.", "Fatal Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } return ls; } private List ReadActionsFromLines(List linelist) { List<(int linenum, string text)> lineSource = new List<(int linenum, string text)>(); for (int l = 0; l < linelist.Count; l++) { string cur = linelist[l]; if (!string.IsNullOrWhiteSpace(cur)) { lineSource.Add((l, cur)); } } List ls = new List(); int i = 0; try { uint actionNumber = 0; while (i < lineSource.Count) { if (actionNumber == 0) { int positionOfActionNumber; int positionOfActionKeyword = lineSource[i].text.IndexOf(ContainerTypes.Action.ToString(), StringComparison.InvariantCultureIgnoreCase); if (positionOfActionKeyword > 0) { MessageBox.Show("Unrecognized container keyword: \"" + lineSource[i] + '"', "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } else if (positionOfActionKeyword < 0) { i++; continue; } else { if ((positionOfActionNumber = lineSource[i].text.IndexOfFirstNumber()) < positionOfActionKeyword) { MessageBox.Show("Unspecified Action label.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } } actionNumber = uint.Parse(lineSource[i].text.Substring(positionOfActionNumber).Split()[0].Replace(":", "")); i++; } List cmdList = new List(); /* Read script actions */ do { ScriptAction toAdd = new ScriptAction(lineSource[i].text, lineSource[i].linenum + 1); if (toAdd.id is null) { return null; } cmdList.Add(toAdd); } while (!lineSource[i++].text.IgnoreCaseEquals(RomInfo.ScriptActionNamesDict[0x00FE])); ls.Add(new ScriptActionContainer(actionNumber, commands: cmdList)); actionNumber = 0; } } catch (ArgumentOutOfRangeException) { MessageBox.Show($"Unexpectedly reached end of lines.\n\n" + $"Last line index: {i}.\n" + $"Managed to parse {ls.Count} Command Containers.", "Fatal Error", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } return ls; } public override byte[] ToByteArray() { MemoryStream newData = new MemoryStream(); using (BinaryWriter writer = new BinaryWriter(newData)) { List scriptOffsets = new List(); //uint OFFSET, int Function/Script/Action ID List functionOffsets = new List(); List actionOffsets = new List(); List refList = new List(); /* Allocate enough space for script pointers, which we do not know yet */ try { writer.BaseStream.Position += allScripts.Count * 0x4; writer.Write((ushort)0xFD13); // Signal the end of header section List useScriptCallers = new List(); /* Write scripts */ foreach (ScriptCommandContainer currentScript in allScripts) { if (currentScript.usedScriptID == -1) { scriptOffsets.Add(new ContainerReference() { ID = currentScript.manualUserID, offsetInFile = (uint)writer.BaseStream.Position } ); foreach (ScriptCommand currentCmd in currentScript.commands) { writer.Write((ushort)currentCmd.id); //System.Diagnostics.Debug.Write(BitConverter.ToString(BitConverter.GetBytes(commandID)) + " "); List parameterList = currentCmd.cmdParams; foreach (byte[] b in parameterList) { writer.Write(b); //System.Diagnostics.Debug.WriteLine(BitConverter.ToString(parameterList[k]) + " "); } /* If command calls a function/movement, store reference position */ AddReference(ref refList, (ushort)currentCmd.id, parameterList, (int)writer.BaseStream.Position, currentScript); } } else { useScriptCallers.Add(currentScript); } } int scriptsCount = scriptOffsets.Count; foreach (ScriptCommandContainer caller in useScriptCallers) { for (int i = 0; i < scriptsCount; i++) { ContainerReference scriptReference = scriptOffsets[i]; if (scriptReference.ID == caller.usedScriptID) { scriptOffsets.Add(new ContainerReference() { ID = caller.manualUserID, offsetInFile = scriptReference.offsetInFile }); // If script has UseScript, copy offset } } } /* Write functions */ foreach (ScriptCommandContainer currentFunction in allFunctions) { if (currentFunction.usedScriptID == -1) { functionOffsets.Add(new ContainerReference() { ID = currentFunction.manualUserID, offsetInFile = (uint)writer.BaseStream.Position } ); foreach (ScriptCommand currentCmd in currentFunction.commands) { writer.Write((ushort)currentCmd.id); //System.Diagnostics.Debug.Write(BitConverter.ToString(BitConverter.GetBytes(commandID)) + " "); List parameterList = currentCmd.cmdParams; foreach (byte[] b in parameterList) { writer.Write(b); //System.Diagnostics.Debug.Write(BitConverter.ToString(parameterList[k]) + " "); } /* If command calls a function/movement, store reference position */ AddReference(ref refList, (ushort)currentCmd.id, parameterList, (int)writer.BaseStream.Position, currentFunction); } } else { int functionUsescript = currentFunction.usedScriptID - 1; if (functionUsescript >= scriptOffsets.Count) { MessageBox.Show($"Function #{currentFunction.manualUserID} refers to Script {currentFunction.usedScriptID}, which does not exist.\n" + $"This Script File can't be saved.", "Can't resolve UseScript reference", MessageBoxButtons.OK, MessageBoxIcon.Error); return null; } functionOffsets.Add(new ContainerReference() { ID = currentFunction.manualUserID, offsetInFile = scriptOffsets.Find(x => x.ID == currentFunction.usedScriptID).offsetInFile }); } } // Movements must be halfword-aligned if (writer.BaseStream.Position % 2 == 1) { //Check if the writer's head is on an odd byte writer.Write((byte)0x00); //Add padding } /* Write movements */ foreach (ScriptActionContainer currentAction in allActions) { actionOffsets.Add(new ContainerReference() { ID = currentAction.manualUserID, offsetInFile = (uint)writer.BaseStream.Position }); foreach (ScriptAction currentCmd in currentAction.commands) { writer.Write((ushort)currentCmd.id); writer.Write((ushort)currentCmd.repetitionCount); } } /* Write script offsets to header */ writer.BaseStream.Position = 0x0; scriptOffsets = scriptOffsets.OrderBy(x => x.ID).ToList(); //Write script offsets to header in the correct order for (int i = 0; i < scriptOffsets.Count; i++) { writer.Write(scriptOffsets[i].offsetInFile - (uint)writer.BaseStream.Position - 0x4); } SortedSet undeclaredFuncs = new SortedSet(); SortedSet undeclaredActions = new SortedSet(); SortedSet uninvokedFuncs = new SortedSet(allFunctions.Select(x => x.manualUserID).ToArray()); SortedSet unreferencedActions = new SortedSet(allActions.Select(x => x.manualUserID).ToArray()); //refList = refList.OrderBy(x => x.invokedID).ToList(); //Sorting is not necessary, after all... for (int i = 0; i < refList.Count; i++) { writer.BaseStream.Position = refList[i].invokedAt; //place seek head on parameter that is supposed to store the jump address ContainerReference result; if (refList[i].typeOfInvoked is ContainerTypes.Action) { //isApplyMovement result = actionOffsets.Find(entry => entry.ID == refList[i].invokedID); if (result.Equals(default(ContainerReference))) { undeclaredActions.Add(refList[i].invokedID); } else { int relativeOffset = (int)(result.offsetInFile - refList[i].invokedAt - 4); writer.Write(relativeOffset); unreferencedActions.Remove(refList[i].invokedID); } } else { result = functionOffsets.Find(entry => entry.ID == refList[i].invokedID); if (result.Equals(default(ContainerReference))) { undeclaredFuncs.Add(refList[i].invokedID); } else { int relativeOffset = (int)(result.offsetInFile - refList[i].invokedAt - 4); writer.Write(relativeOffset); if (FunctionIsInvoked(refList, uninvokedFuncs, refList[i].invokedID, 0)) { uninvokedFuncs.Remove(refList[i].invokedID); } //if (refList[i].callerType != containerTypes.Function || // (refList[i].callerType == refList[i].invokedType && refList[i].callerID == refList[i].invokedID) || // !uninvokedFuncs.Contains(refList[i].callerID)) { //remove reference if caller is a script, or if caller calls itself, or if caller is a function that's been invoked already // uninvokedFuncs.Remove(refList[i].invokedID); //} } } } //Error check string errorMsg = ""; if (undeclaredFuncs.Count > 0) { string[] errorFunctionsUndeclared = undeclaredFuncs.ToArray().Select(x => x.ToString()).ToArray(); errorMsg += "These Functions have been invoked but not declared: " + Environment.NewLine + string.Join(separator: ",", errorFunctionsUndeclared); errorMsg += Environment.NewLine; } if (undeclaredActions.Count > 0) { string[] errorActionsUndeclared = undeclaredActions.ToArray().Select(x => x.ToString()).ToArray(); errorMsg += "These Actions have been referenced but not declared: " + Environment.NewLine + string.Join(separator: ",", errorActionsUndeclared); errorMsg += Environment.NewLine; } if (!string.IsNullOrEmpty(errorMsg)) { MessageBox.Show(errorMsg + Environment.NewLine + "This Script File has not been overwritten since it can not be saved.", "Error!", MessageBoxButtons.OK, MessageBoxIcon.Error); errorMsg = ""; return null; } if (uninvokedFuncs.Count > 0) { string[] orphanedFunctions = uninvokedFuncs.ToArray().Select(x => x.ToString()).ToArray(); errorMsg += "Unused Function IDs detected: " + Environment.NewLine + string.Join(", ", orphanedFunctions); errorMsg += Environment.NewLine; errorMsg += "\nIn order for a Function to be saved, it must be invoked by a Script or by another used Function."; errorMsg += Environment.NewLine; errorMsg += Environment.NewLine; } if (unreferencedActions.Count > 0) { string[] orphanedActions = unreferencedActions.ToArray().Select(x => x.ToString()).ToArray(); errorMsg += "Unused Action IDs detected: " + Environment.NewLine + string.Join(", ", orphanedActions); errorMsg += Environment.NewLine; errorMsg += "\nIn order for an Action to be saved, it must be called by a Script or by a used Function."; errorMsg += Environment.NewLine; errorMsg += Environment.NewLine; } if (!string.IsNullOrEmpty(errorMsg)) { MessageBox.Show(errorMsg + Environment.NewLine + "Remember that every unused Function or Action is always lost upon reloading the Script File.", "Warning!", MessageBoxButtons.OK, MessageBoxIcon.Information); errorMsg = ""; } } catch (NullReferenceException nre) { AppLogger.Error(nre.ToString()); return null; } } return newData.ToArray(); } private bool FunctionIsInvoked(List refList, SortedSet uninvokedFuncsSet, uint funcID, int callCount = 0, uint? excludedCaller = null) { if (callCount >= 30) { MessageBox.Show("Something went very wrong saving this Script File!" + "\nIt is recommended that you backup its code somewhere, to avoid losing progress.", "Fatal error", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } AppLogger.Debug("Checking calls of function " + funcID + (excludedCaller == null ? "" : " excluding Function " + excludedCaller + " as the caller.")); if (!uninvokedFuncsSet.Contains(funcID)) { AppLogger.Debug("Function " + funcID + " has already been invoked before. Nothing to check."); return true; //Abort } if (refList is null || refList.Count <= 0) { return false; } //Find the first instance of funcID being called, excluding calls coming from an excludedCaller //if excludedCaller is null, there's nothing to exclude: a normal search is performed. ScriptReference sr = refList.Find(x => x.invokedID == funcID && (excludedCaller == null || x.callerID != excludedCaller)); if (sr is null) { AppLogger.Debug("No reference found!!!"); return false; } if (sr.typeOfCaller is ContainerTypes.Script) { AppLogger.Debug("Function " + funcID + " is directly called by Script " + sr.callerID); return true; } if (sr.typeOfCaller is ContainerTypes.Function) { if (FunctionIsInvoked(refList, uninvokedFuncsSet, sr.callerID, ++callCount, excludedCaller: sr.invokedID)) { //check if caller function is invoked as well AppLogger.Debug("Function " + funcID + " is called by Function " + sr.callerID); return true; } } AppLogger.Debug("Function " + funcID + " is unused"); return false; } public bool SaveToFileDefaultDir(int IDtoReplace, bool showSuccessMessage = true) { bool success = SaveToFileDefaultDir(RomInfo.DirNames.scripts, IDtoReplace, showSuccessMessage); if (success) { WritePlainTextFile(); } return success; } public void SaveToFileExplorePath(string suggestedFileName, bool blindmode) { SaveFileDialog sf = new SaveFileDialog { Filter = "Gen IV Script File (*.scr)|*.scr" }; if (!string.IsNullOrEmpty(suggestedFileName)) { sf.FileName = suggestedFileName; } if (sf.ShowDialog() != DialogResult.OK) { return; } if (blindmode) { string path = Filesystem.GetScriptPath(fileID); File.Copy(path, sf.FileName, overwrite: true); string msg = ""; if (!isLevelScript) { msg += "The last saved version of this "; } MessageBox.Show(msg + GetType().Name + " has been exported successfully.", "", MessageBoxButtons.OK, MessageBoxIcon.Information); } else { this.SaveToFile(sf.FileName, showSuccessMessage: true); } } } }