scripts plaintext parsing + simple caching for consecutive searches

This commit is contained in:
KalaayPT
2025-10-22 18:44:07 +02:00
parent 3b297e89c8
commit d1e42b1a73
5 changed files with 678 additions and 106 deletions

View File

@@ -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)

View File

@@ -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<ScriptCommandContainer>(), new List<ScriptCommandContainer>(), new List<ScriptActionContainer>(), 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);
}
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Shared logic to load plaintext content into editor text areas.
/// Used by both auto-loading and manual import.
/// </summary>
/// <param name="content">The plaintext file content</param>
/// <param name="populateNavLists">Whether to populate navigation lists (true for load, false for import that's followed by reload)</param>
/// <param name="isLevelScript">Output: whether this is a level script</param>
/// <returns>True if successful, false if format is invalid</returns>
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;
}
/// <summary>
/// Populates navigation listbox by counting "Type X:" headers in text
/// </summary>
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<ScriptFile> getScriptsToSearch()
{
List<ScriptFile> scriptsToSearch = new List<ScriptFile>();
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<ScriptFile> scriptsToSearch = getScriptsToSearch();
string searchString = searchInScriptsTextBox.Text;
Func<string, bool> searchCriteriaCS = (string s) => s.IndexOf(searchString, StringComparison.InvariantCulture) >= 0;
Func<string, bool> searchCriteriaCI = (string s) => s.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0;
Func<string, bool> searchCriteria = scriptSearchCaseSensitiveCheckBox.Checked ? searchCriteriaCS : searchCriteriaCI;
List<ScriptEditorSearchResult> results = new List<ScriptEditorSearchResult>();
foreach (ScriptFile scriptFile in scriptsToSearch)
bw.DoWork += (_sender, args) =>
{
List<ScriptEditorSearchResult> scriptResults = SearchInScripts(scriptFile, scriptFile.allScripts, searchCriteria);
List<ScriptEditorSearchResult> functionResults = SearchInScripts(scriptFile, scriptFile.allFunctions, searchCriteria);
// List<ScriptEditorSearchResult> 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<ScriptFile> scriptsToSearch = getScriptsToSearch();
string searchString = searchInScriptsTextBox.Text;
Func<string, bool> searchCriteriaCS = (string s) => s.IndexOf(searchString, StringComparison.InvariantCulture) >= 0;
Func<string, bool> searchCriteriaCI = (string s) => s.IndexOf(searchString, StringComparison.InvariantCultureIgnoreCase) >= 0;
Func<string, bool> searchCriteria = scriptSearchCaseSensitiveCheckBox.Checked ? searchCriteriaCS : searchCriteriaCI;
List<ScriptEditorSearchResult> results = new List<ScriptEditorSearchResult>();
foreach (ScriptFile scriptFile in scriptsToSearch)
{
List<ScriptEditorSearchResult> scriptResults = SearchInScripts(scriptFile, scriptFile.allScripts, searchCriteria);
List<ScriptEditorSearchResult> functionResults = SearchInScripts(scriptFile, scriptFile.allFunctions, searchCriteria);
// List<ScriptEditorSearchResult> 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<ScriptEditorSearchResult> results = new List<ScriptEditorSearchResult>();
// 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}";
}
}

View File

@@ -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)

View File

@@ -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<byte[]> 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<byte[]>();
@@ -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;
}
}
}

View File

@@ -14,6 +14,18 @@ namespace DSPRE.ROMFiles
/// </summary>
public class ScriptFile : RomFile
{
// Cache for parsed plaintext scripts to avoid reparsing during search
private static Dictionary<string, (DateTime timestamp, ScriptFile cached)> plaintextCache = new Dictionary<string, (DateTime, ScriptFile)>();
/// <summary>
/// Clears the plaintext script cache. Useful when closing ROM or reloading.
/// </summary>
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);
}
/// <summary>
/// Gets the file paths for both binary and plaintext versions of a script file
/// </summary>
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);
}
/// <summary>
/// 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
/// </summary>
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;
}
/// <summary>
/// Core parsing logic for reading plaintext script files
/// </summary>
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;
}
}
/// <summary>
/// Helper method to format script/function containers to plaintext (matches ScriptEditor.displayScriptFile logic)
/// </summary>
private static void AppendContainerList(StringBuilder content, List<ScriptCommandContainer> 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();
}
}
/// <summary>
/// Helper method to format action containers to plaintext (matches ScriptEditor.displayScriptFileActions logic)
/// </summary>
private static void AppendActionList(StringBuilder content, List<ScriptActionContainer> 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();
}
}
/// <summary>
/// Writes the script file to plaintext .script format in expanded/scripts/
/// </summary>
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}");
}
}
/// <summary>
/// Exports all script files from the ROM to expanded/scripts/ on initial load
/// This ensures the expanded directory is populated with plaintext versions
/// </summary>
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}");
}
}
/// <summary>
/// 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()
/// </summary>
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)