From d1e42b1a73cbab21bcc1abfb2e9645cd140bcd95 Mon Sep 17 00:00:00 2001 From: KalaayPT Date: Wed, 22 Oct 2025 18:44:07 +0200 Subject: [PATCH] scripts plaintext parsing + simple caching for consecutive searches --- CLAUDE.md | 7 + DS_Map/Editors/ScriptEditor.cs | 246 +++++++++++++----- DS_Map/Main Window.cs | 16 +- DS_Map/ROMFiles/ScriptCommand.cs | 104 +++++--- DS_Map/ROMFiles/ScriptFile.cs | 411 ++++++++++++++++++++++++++++++- 5 files changed, 678 insertions(+), 106 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ff546a..86cef29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,13 @@ Game-specific paths are defined in `RomInfo.gameDirs`. ## Development Guidelines +### Code Style and Formatting + +**IMPORTANT: Avoid Useless Comments** +- Do NOT write comments that simply restate what the code does +- Comments should only explain "why" the code exists, not "what" it does +- Only add comments when there's a non-obvious reason, tricky logic, or important context + ### ROM File Editing Pattern When editing ROM data: 1. Load ROM project (unpacks to working directory) diff --git a/DS_Map/Editors/ScriptEditor.cs b/DS_Map/Editors/ScriptEditor.cs index ca3c03d..7c701e2 100644 --- a/DS_Map/Editors/ScriptEditor.cs +++ b/DS_Map/Editors/ScriptEditor.cs @@ -618,8 +618,7 @@ namespace DSPRE.Editors Helpers.DisableHandlers(); ScriptFile lastScriptFile = currentScriptFile; - // currentScriptFile = (ScriptFile)selectScriptFileComboBox.SelectedItem; - currentScriptFile = new ScriptFile(selectScriptFileComboBox.SelectedIndex); // Load script file + int fileID = selectScriptFileComboBox.SelectedIndex; ScriptTextArea.ClearAll(); FunctionTextArea.ClearAll(); @@ -629,6 +628,18 @@ namespace DSPRE.Editors functionsNavListbox.Items.Clear(); actionsNavListbox.Items.Clear(); + if (TryLoadPlaintextDirect(fileID, out bool isLevelScript)) + { + // Create a minimal ScriptFile just to hold the fileID for saving later + currentScriptFile = new ScriptFile(new List(), new List(), new List(), fileID); + currentScriptFile.isLevelScript = isLevelScript; + } + else + { + // Fallback: load and parse normally (binary or older plaintext) + currentScriptFile = new ScriptFile(fileID); + } + //prevent buttons from flickering when the combobox selection changes bool typeChanged = true; if (lastScriptFile != null) @@ -888,40 +899,12 @@ namespace DSPRE.Editors { string content = File.ReadAllText(of.FileName); - // 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) + // Use shared loading logic (don't populate nav lists - user will save/reload) + if (!LoadPlaintextIntoEditor(content, false, out _)) { throw new FormatException("Invalid script file format. Missing required section headers."); } - // Extract each section's content - string scripts = content.Substring( - scriptsStart + SCRIPTS_HEADER.Length, - functionsStart - (scriptsStart + SCRIPTS_HEADER.Length) - ).Trim(); - - string functions = content.Substring( - functionsStart + FUNCTIONS_HEADER.Length, - actionsStart - (functionsStart + FUNCTIONS_HEADER.Length) - ).Trim(); - - string actions = content.Substring( - actionsStart + ACTIONS_HEADER.Length - ).Trim(); - - // Update text areas - ScriptTextArea.Text = scripts; - FunctionTextArea.Text = functions; - ActionTextArea.Text = actions; - MessageBox.Show("Script file imported successfully!", "", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) @@ -1127,33 +1110,158 @@ namespace DSPRE.Editors searchInScriptsButton_Click(null, null); } } + /// + /// Tries to load plaintext directly without parsing for fast display. + /// Only works if plaintext exists and is newer than binary. + /// Returns true if successful, false if parsing is needed. + /// + private bool TryLoadPlaintextDirect(int fileID, out bool isLevelScript) + { + isLevelScript = false; + + var (binPath, txtPath) = ScriptFile.GetFilePaths(fileID); + + if (!File.Exists(txtPath)) + { + return false; + } + + if (File.Exists(binPath) && File.GetLastWriteTimeUtc(txtPath) < File.GetLastWriteTimeUtc(binPath)) + { + return false; + } + + try + { + string content = File.ReadAllText(txtPath); + if (!LoadPlaintextIntoEditor(content, true, out isLevelScript)) + { + return false; + } + + AppLogger.Info($"Script file {fileID:D4} loaded directly from plaintext (fast path)"); + return true; + } + catch (Exception ex) + { + AppLogger.Error($"Fast plaintext loading failed for {fileID:D4}: {ex.Message}"); + return false; + } + } + + /// + /// Shared logic to load plaintext content into editor text areas. + /// Used by both auto-loading and manual import. + /// + /// The plaintext file content + /// Whether to populate navigation lists (true for load, false for import that's followed by reload) + /// Output: whether this is a level script + /// True if successful, false if format is invalid + private bool LoadPlaintextIntoEditor(string content, bool populateNavLists, out bool isLevelScript) + { + isLevelScript = false; + + // Extract 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) + { + return false; // Invalid format + } + + 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(); + + // Directly dump text into text areas (no parsing!) + ScriptTextArea.Text = scriptsSection; + FunctionTextArea.Text = functionsSection; + ActionTextArea.Text = actionsSection; + + // Optionally populate navigation lists + if (populateNavLists) + { + PopulateNavListFromText(scriptsSection, scriptsNavListbox, "Script"); + PopulateNavListFromText(functionsSection, functionsNavListbox, "Function"); + PopulateNavListFromText(actionsSection, actionsNavListbox, "Action"); + } + + // Check if it's a level script (no scripts, only functions) + isLevelScript = string.IsNullOrWhiteSpace(scriptsSection) || !scriptsSection.Contains("Script "); + + return true; + } + + /// + /// Populates navigation listbox by counting "Type X:" headers in text + /// + private void PopulateNavListFromText(string text, ListBox navListBox, string containerType) + { + if (string.IsNullOrWhiteSpace(text)) + return; + + var lines = text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + foreach (var line in lines) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith(containerType + " ", StringComparison.InvariantCultureIgnoreCase) && trimmed.Contains(':')) + { + // Extract "Script 1:" or "Function 2:" etc. + int colonIndex = trimmed.IndexOf(':'); + if (colonIndex > 0) + { + string header = trimmed.Substring(0, colonIndex); + navListBox.Items.Add(header); + } + } + } + } + public List getScriptsToSearch() { List scriptsToSearch = new List(); if (searchOnlyCurrentScriptCheckBox.Checked) { - this.UIThread(() => { + this.UIThread(() => + { searchProgressBar.Maximum = 1; }); int i = selectScriptFileComboBox.SelectedIndex; ScriptFile scriptFile = new ScriptFile(i); - AppLogger.Debug("Attempting to load script " + scriptFile.fileID); scriptsToSearch.Add(scriptFile); - this.UIThread(() => { + this.UIThread(() => + { searchProgressBar.IncrementNoAnimation(); }); } else { - this.UIThread(() => { + this.UIThread(() => + { searchProgressBar.Maximum = selectScriptFileComboBox.Items.Count; }); for (int i = 0; i < selectScriptFileComboBox.Items.Count; i++) { ScriptFile scriptFile = new ScriptFile(i); - AppLogger.Debug("Attempting to load script " + scriptFile.fileID); scriptsToSearch.Add(scriptFile); - this.UIThread(() => { + this.UIThread(() => + { searchProgressBar.IncrementNoAnimation(); }); } @@ -1167,29 +1275,32 @@ namespace DSPRE.Editors return; } BackgroundWorker bw = new BackgroundWorker(); - bw.DoWork += (_sender, args) => { - this.UIThread(() => { - searchInScriptsResultListBox.Items.Clear(); - searchProgressBar.Value = 0; - }); - List scriptsToSearch = getScriptsToSearch(); - - string searchString = searchInScriptsTextBox.Text; - Func searchCriteriaCS = (string s) => s.IndexOf(searchString, StringComparison.InvariantCulture) >= 0; - Func searchCriteriaCI = (string s) => s.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0; - Func searchCriteria = scriptSearchCaseSensitiveCheckBox.Checked ? searchCriteriaCS : searchCriteriaCI; - - List results = new List(); - foreach (ScriptFile scriptFile in scriptsToSearch) + bw.DoWork += (_sender, args) => { - List scriptResults = SearchInScripts(scriptFile, scriptFile.allScripts, searchCriteria); - List functionResults = SearchInScripts(scriptFile, scriptFile.allFunctions, searchCriteria); - // List actionResults = SearchInScripts(scriptFile, scriptFile.allActions, searchCriteria); - results.AddRange(scriptResults); - results.AddRange(functionResults); - // results.AddRange(actionResults); - } - this.UIThread(() => { + this.UIThread(() => + { + searchInScriptsResultListBox.Items.Clear(); + searchProgressBar.Value = 0; + }); + List scriptsToSearch = getScriptsToSearch(); + + string searchString = searchInScriptsTextBox.Text; + Func searchCriteriaCS = (string s) => s.IndexOf(searchString, StringComparison.InvariantCulture) >= 0; + Func searchCriteriaCI = (string s) => s.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0; + Func searchCriteria = scriptSearchCaseSensitiveCheckBox.Checked ? searchCriteriaCS : searchCriteriaCI; + + List results = new List(); + foreach (ScriptFile scriptFile in scriptsToSearch) + { + List scriptResults = SearchInScripts(scriptFile, scriptFile.allScripts, searchCriteria); + List functionResults = SearchInScripts(scriptFile, scriptFile.allFunctions, searchCriteria); + // List actionResults = SearchInScripts(scriptFile, scriptFile.allActions, searchCriteria); + results.AddRange(scriptResults); + results.AddRange(functionResults); + // results.AddRange(actionResults); + } + this.UIThread(() => + { searchInScriptsResultListBox.Items.AddRange(results.ToArray()); searchProgressBar.Value = 0; }); @@ -1202,6 +1313,12 @@ namespace DSPRE.Editors { List results = new List(); + // Check if plaintext parsing failed + if (commandContainers == null) + { + return results; + } + for (int j = 0; j < commandContainers.Count; j++) { if (commandContainers[j].commands is null) @@ -1278,7 +1395,8 @@ namespace DSPRE.Editors } - public class ScriptEditorSearchResult { + public class ScriptEditorSearchResult + { public readonly ScriptFile scriptFile; public readonly ScriptFile.ContainerTypes containerType; public readonly int commandNumber; @@ -1286,7 +1404,8 @@ namespace DSPRE.Editors public const int ResultsPadding = 1; - public ScriptEditorSearchResult(ScriptFile scriptFile, ScriptFile.ContainerTypes containerType, int commandNumber, ScriptCommand scriptCommand) { + public ScriptEditorSearchResult(ScriptFile scriptFile, ScriptFile.ContainerTypes containerType, int commandNumber, ScriptCommand scriptCommand) + { this.scriptFile = scriptFile; this.containerType = containerType; this.commandNumber = commandNumber; @@ -1295,7 +1414,8 @@ namespace DSPRE.Editors public string CommandBlockOpen { get { return $"{containerType} {commandNumber}:"; } } - public override string ToString() { + public override string ToString() + { return $"File {scriptFile.fileID} - {CommandBlockOpen} {scriptCommand.name}"; } } diff --git a/DS_Map/Main Window.cs b/DS_Map/Main Window.cs index fc88236..f713125 100644 --- a/DS_Map/Main Window.cs +++ b/DS_Map/Main Window.cs @@ -1,4 +1,4 @@ -using DSPRE.Editors; +using DSPRE.Editors; using DSPRE.Editors.BtxEditor; using DSPRE.Resources; using DSPRE.ROMFiles; @@ -982,6 +982,8 @@ namespace DSPRE Helpers.statusLabelMessage(); this.Text += " - " + RomInfo.projectName; + + ScriptFile.ExportAllScripts(); } private void saveRom_Click(object sender, EventArgs e) @@ -1012,6 +1014,12 @@ namespace DSPRE return; } + if (!ScriptFile.BuildRequiredBins()) + { + MessageBox.Show("An error occurred while rebuilding script files. Save aborted.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + Helpers.statusLabelMessage("Repacking NARCS..."); Update(); @@ -1270,12 +1278,12 @@ namespace DSPRE case GameFamilies.Plat: WildEditorDPPt wildEditorDppt = new WildEditorDPPt(wildPokeUnpackedPath, RomInfo.GetPokemonNames(), encToOpen, EditorPanels.headerEditor.internalNames.Count); - wildEditorDppt.Show(); + wildEditorDppt.Show(); break; default: WildEditorHGSS wildEditorHgss = new WildEditorHGSS(wildPokeUnpackedPath, RomInfo.GetPokemonNames(), encToOpen, EditorPanels.headerEditor.internalNames.Count); - wildEditorHgss.Show(); + wildEditorHgss.Show(); break; } Helpers.statusLabelMessage(); @@ -1730,7 +1738,7 @@ namespace DSPRE private void settingsToolStripMenuItem_Click(object sender, EventArgs e) { SettingsWindow editor = new SettingsWindow(); - editor.Show(); + editor.Show(); } private void pokemonDataEditorToolStripMenuItem_Click(object sender, EventArgs e) diff --git a/DS_Map/ROMFiles/ScriptCommand.cs b/DS_Map/ROMFiles/ScriptCommand.cs index b4e0292..27b0624 100644 --- a/DS_Map/ROMFiles/ScriptCommand.cs +++ b/DS_Map/ROMFiles/ScriptCommand.cs @@ -1,4 +1,4 @@ -using DSPRE.Resources; +using DSPRE.Resources; using System; using System.Collections.Generic; using System.Globalization; @@ -6,8 +6,10 @@ using System.Linq; using System.Text; using System.Windows.Forms; -namespace DSPRE.ROMFiles { - public class ScriptCommand { +namespace DSPRE.ROMFiles +{ + public class ScriptCommand + { public ushort? id; public List cmdParams; @@ -52,7 +54,8 @@ namespace DSPRE.ROMFiles { } } - public ScriptCommand(string wholeLine, int lineNumber = 0) { + public ScriptCommand(string wholeLine, int lineNumber = 0) + { name = wholeLine; cmdParams = new List(); @@ -60,17 +63,26 @@ namespace DSPRE.ROMFiles { string[] nameParts = processedLine.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); // Separate command code from parameters /* Get command id, which is always first in the description */ - if (RomInfo.ScriptCommandNamesReverseDict.TryGetValue(nameParts[0].ToLower(), out ushort cmdID)) { + if (RomInfo.ScriptCommandNamesReverseDict.TryGetValue(nameParts[0].ToLower(), out ushort cmdID)) + { id = cmdID; - } else { - try { + } + else + { + try + { id = ushort.Parse(nameParts[0].PurgeSpecial(ScriptFile.specialChars), nameParts[0].GetNumberStyle()); - } catch { + } + catch + { string details; - if (wholeLine.Contains(':') && wholeLine.ContainsNumber()) { + if (wholeLine.Contains(':') && wholeLine.ContainsNumber()) + { details = "This probably means you forgot to \"End\" the Script or Function above it."; details += Environment.NewLine + "Please, also note that only Functions can be terminated\nwith \"Return\"."; - } else { + } + else + { details = "Are you sure it's a proper Script Command?"; } @@ -86,7 +98,8 @@ namespace DSPRE.ROMFiles { int paramLength = 0; int paramsProcessed = 0; - if (parametersSizeArr.Length > 0 && parametersSizeArr.First() == 0xFF) { + if (parametersSizeArr.Length > 0 && parametersSizeArr.First() == 0xFF) + { int firstParamValue = int.Parse(nameParts[1].PurgeSpecial(ScriptFile.specialChars), nameParts[1].GetNumberStyle()); byte firstParamSize = parametersSizeArr[1]; @@ -97,25 +110,27 @@ namespace DSPRE.ROMFiles { int optionsCount = 0; bool found = false; - while (i < parametersSizeArr.Length) { + while (i < parametersSizeArr.Length) + { paramLength = parametersSizeArr[i + 1]; - if (parametersSizeArr[i] == firstParamValue) { + if (parametersSizeArr[i] == firstParamValue) + { //Firstly, build subarray of parameter sizes, starting from the chosen option [firstParamValue] //FOR EXAMPLE: CMD 0x235 and firstParamValue = 5 - // { 0xFF, 2, - // 0, 1, 2, - // 1, 3, 2, 2, 2, - // 2, 0, - // 3, 3, 2, 2, 2, - // 4, 2, 2, 2, - // 5, 3, (2, 2, 2) => this will be the parameters subarray + // { 0xFF, 2, + // 0, 1, 2, + // 1, 3, 2, 2, 2, + // 2, 0, + // 3, 3, 2, 2, 2, + // 4, 2, 2, 2, + // 5, 3, (2, 2, 2) => this will be the parameters subarray // 6, 1, 2 - // }, + // }, byte[] subParametersSize = parametersSizeArr.SubArray(i + 2, paramLength++); - //Create a slightly bigger temp array + //Create a slightly bigger temp array byte[] temp = new byte[1 + subParametersSize.Length]; //Store the size of the firstParamValue there @@ -134,33 +149,44 @@ namespace DSPRE.ROMFiles { optionsCount++; } - if (!found) { + if (!found) + { MessageBox.Show($"Command {nameParts[0]} is a special Script Command.\n" + $"The value of the first parameter must be a number in the range [0 - {optionsCount}].\n\n" + $"Line {lineNumber}: {wholeLine}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); id = null; return; } - } else if (parametersSizeArr.Length == 1 && parametersSizeArr.First() == 0) { + } + else if (parametersSizeArr.Length == 1 && parametersSizeArr.First() == 0) + { paramLength = 0; - } else { + } + else + { paramLength = parametersSizeArr.Length; } - if (nameParts.Length - 1 == paramLength) { - for (int i = paramsProcessed; i < paramLength; i++) { - AppLogger.Debug($"Parameter #{i}: {nameParts[i + 1]}"); + if (nameParts.Length - 1 == paramLength) + { + for (int i = paramsProcessed; i < paramLength; i++) + { + //AppLogger.Debug($"Parameter #{i}: {nameParts[i + 1]}"); - if (RomInfo.ScriptComparisonOperatorsReverseDict.TryGetValue(nameParts[i + 1].ToLower(), out cmdID)) { //Check succeeds when command is like "asdfg LESS" or "asdfg DIFFERENT" + if (RomInfo.ScriptComparisonOperatorsReverseDict.TryGetValue(nameParts[i + 1].ToLower(), out cmdID)) + { //Check succeeds when command is like "asdfg LESS" or "asdfg DIFFERENT" cmdParams.Add(new byte[] { (byte)cmdID }); - } else { //Not a comparison + } + else + { //Not a comparison /* Convert strings of parameters to the correct datatypes */ NumberStyles numStyle = nameParts[i + 1].GetNumberStyle(); - if (!nameParts[i + 1].StartsWith("SEQ_") - && !nameParts[i + 1].StartsWith("SPECIES_") + if (!nameParts[i + 1].StartsWith("SEQ_") + && !nameParts[i + 1].StartsWith("SPECIES_") && !nameParts[i + 1].StartsWith("ITEM_") && !nameParts[i + 1].StartsWith("MOVE_") - && !nameParts[i + 1].StartsWith("TRAINER_")) { + && !nameParts[i + 1].StartsWith("TRAINER_")) + { nameParts[i + 1] = nameParts[i + 1].PurgeSpecial(ScriptFile.specialChars); } @@ -251,12 +277,16 @@ namespace DSPRE.ROMFiles { } catch (OverflowException) { - MessageBox.Show($"Argument {nameParts[i + 1]} at line {lineNumber} is not in the range [0, {Math.Pow(2, 8 * parametersSizeArr[i]) - 1}].", "Argument error", MessageBoxButtons.OK, MessageBoxIcon.Error); + string errorMsg = $"Argument {nameParts[i + 1]} at line {lineNumber} is not in the range [0, {Math.Pow(2, 8 * parametersSizeArr[i]) - 1}]."; + AppLogger.Error($"ScriptCommand parse error: {errorMsg} | Command: {nameParts[0]} (ID: 0x{id:X3}) | Full line: {wholeLine} | Expected param size: {parametersSizeArr[i]} bytes | This may indicate a database error for conditional commands."); + MessageBox.Show(errorMsg + $"\n\nCommand: {nameParts[0]} (ID: 0x{id:X3})\nFull line: {wholeLine}\n\nNote: If this is a conditional command like UnionGroup, check your script command database for parameter size errors.", "Argument error", MessageBoxButtons.OK, MessageBoxIcon.Error); id = null; } } } - } else { + } + else + { MessageBox.Show($"Wrong number of parameters for command {nameParts[0]} at line {lineNumber}.\n" + $"Received: {nameParts.Length - 1}\n" + $"Expected: {paramLength}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); @@ -324,7 +354,8 @@ namespace DSPRE.ROMFiles { input = input.Trim('[', ']').Replace(" ", "").ToLower(); var closest = possibilities - .Select(x => new { + .Select(x => new + { Name = x, Distance = LevenshteinDistance( input, @@ -401,6 +432,5 @@ namespace DSPRE.ROMFiles { return parameter; } - } } diff --git a/DS_Map/ROMFiles/ScriptFile.cs b/DS_Map/ROMFiles/ScriptFile.cs index a02f15b..c312a7f 100644 --- a/DS_Map/ROMFiles/ScriptFile.cs +++ b/DS_Map/ROMFiles/ScriptFile.cs @@ -14,6 +14,18 @@ namespace DSPRE.ROMFiles /// public class ScriptFile : RomFile { + // Cache for parsed plaintext scripts to avoid reparsing during search + private static Dictionary plaintextCache = new Dictionary(); + + /// + /// Clears the plaintext script cache. Useful when closing ROM or reloading. + /// + public static void ClearPlaintextCache() + { + plaintextCache.Clear(); + AppLogger.Info("Script: Plaintext cache cleared."); + } + public enum ContainerTypes { Function, @@ -189,9 +201,24 @@ namespace DSPRE.ROMFiles } } - public ScriptFile(int fileID, bool readFunctions = true, bool readActions = true) : this(getFileStream(fileID), readFunctions, readActions) + 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); + this.allScripts = tempScript.allScripts; + this.allFunctions = tempScript.allFunctions; + this.allActions = tempScript.allActions; + this.isLevelScript = tempScript.isLevelScript; + } } static FileStream getFileStream(int fileID) @@ -199,6 +226,379 @@ namespace DSPRE.ROMFiles 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; + + 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}"); + } + } + + /// + /// Exports all script files from the ROM to expanded/scripts/ on initial load + /// This ensures the expanded directory is populated with plaintext versions + /// + public static void ExportAllScripts() + { + string expandedDir = Path.Combine(RomInfo.workDir, "expanded", "scripts"); + + // Skip if the directory already has script files (already exported previously) + if (Directory.Exists(expandedDir) && Directory.GetFiles(expandedDir, "*.script").Length > 0) + { + AppLogger.Info($"Script: expanded/scripts already exists with {Directory.GetFiles(expandedDir, "*.script").Length} files, skipping initial export."); + return; + } + + Directory.CreateDirectory(expandedDir); + + 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 + { + // Load from binary only (don't try to read plaintext since we're creating it) + using (var fs = getFileStream(i)) + { + var scriptFile = new ScriptFile(fs, true, true); + scriptFile.fileID = i; + 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}"); + } + } + + AppLogger.Info($"Script: Exported {exportedCount} of {scriptCount} script files to {expandedDir}"); + } + catch (Exception ex) + { + AppLogger.Error($"Failed to export scripts: {ex.Message}"); + } + } + + /// + /// 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 " : ""; @@ -1121,7 +1521,14 @@ namespace DSPRE.ROMFiles public bool SaveToFileDefaultDir(int IDtoReplace, bool showSuccessMessage = true) { - return SaveToFileDefaultDir(RomInfo.DirNames.scripts, IDtoReplace, showSuccessMessage); + bool success = SaveToFileDefaultDir(RomInfo.DirNames.scripts, IDtoReplace, showSuccessMessage); + + if (success) + { + WritePlainTextFile(); + } + + return success; } public void SaveToFileExplorePath(string suggestedFileName, bool blindmode)