mirror of
https://github.com/AdAstra-LD/DS-Pokemon-Rom-Editor.git
synced 2026-05-10 06:01:05 -05:00
1740 lines
76 KiB
C#
1740 lines
76 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Class to store script file data in Pokémon NDS games
|
|
/// </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)>();
|
|
|
|
private static List<(int fileID, ushort commandID, long offset)> invalidCommandsEncountered = new List<(int, ushort, long)>();
|
|
private static bool suppressInvalidCommandErrors = false;
|
|
|
|
/// <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 static List<(int fileID, ushort commandID, long offset)> GetInvalidCommands()
|
|
{
|
|
return new List<(int, ushort, long)>(invalidCommandsEncountered);
|
|
}
|
|
|
|
public static void ClearInvalidCommands()
|
|
{
|
|
invalidCommandsEncountered.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Controls whether invalid command errors should show popups (false) or be collected silently (true)
|
|
/// </summary>
|
|
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<ScriptCommandContainer> allScripts = new List<ScriptCommandContainer>();
|
|
public List<ScriptCommandContainer> allFunctions = new List<ScriptCommandContainer>();
|
|
public List<ScriptActionContainer> allActions = new List<ScriptActionContainer>();
|
|
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<int> scriptOffsets = new List<int>();
|
|
List<int> functionOffsets = new List<int>();
|
|
List<int> movementOffsets = new List<int>();
|
|
|
|
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<ScriptCommand> cmdList = new List<ScriptCommand>();
|
|
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<ScriptCommand> cmdList = new List<ScriptCommand>();
|
|
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<ScriptAction> cmdList = new List<ScriptAction>();
|
|
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);
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloads script command database and reparses all scripts
|
|
/// used when loading a custom database or replacing database through DB Manager
|
|
/// </summary>
|
|
/// <param name="databasePath">Path to the JSON database file to load</param>
|
|
/// <param name="progressCallback">Optional callback to report progress during reparse</param>
|
|
/// <returns>List of remaining invalid commands after reload, empty if all successful</returns>
|
|
public static List<(int fileID, ushort commandID, long offset)> ReloadDatabaseAndReparseAll(string databasePath, Action<int, int> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the hash of the current script command database to detect changes
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// <param name="suppressErrors">If true, collects invalid command errors silently instead of showing popups</param>
|
|
/// <param name="progressCallback">Optional callback to report progress (current index, total count)</param>
|
|
/// <returns>True if export completed, false if cancelled or failed</returns>
|
|
public static bool ExportAllScripts(bool suppressErrors = false, Action<int, int> 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;
|
|
}
|
|
}
|
|
|
|
/// <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 " : "";
|
|
return $"{prefix}Script File " + this.fileID;
|
|
}
|
|
|
|
public ScriptFile(List<ScriptCommandContainer> scripts, List<ScriptCommandContainer> functions, List<ScriptActionContainer> movements, int fileID = -1)
|
|
{
|
|
this.fileID = fileID;
|
|
this.allScripts = scripts;
|
|
this.allFunctions = functions;
|
|
this.allActions = movements;
|
|
this.isLevelScript = false;
|
|
}
|
|
|
|
public ScriptFile(IEnumerable<string> scriptLines, IEnumerable<string> functionLines, IEnumerable<string> 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<int> functionOffsets, ref List<int> actionOffsets)
|
|
{
|
|
ushort id = dataReader.ReadUInt16();
|
|
List<byte[]> parameterList = new List<byte[]>();
|
|
|
|
/* 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<byte[]> parameterList, ref List<int> 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<byte[]> 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<ScriptReference> references, ushort commandID, List<byte[]> 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<string> 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<ScriptCommandContainer> ReadCommandsFromLines(List<string> linelist, ContainerTypes containerType, Func<List<(int linenum, string text)>, int, ushort?, bool> endConditions)
|
|
{
|
|
List<(int linenum, string text)> lineSource = PreprocessLines(linelist);
|
|
|
|
List<ScriptCommandContainer> ls = new List<ScriptCommandContainer>();
|
|
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<ScriptCommand> cmdList = new List<ScriptCommand>();
|
|
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<ScriptActionContainer> ReadActionsFromLines(List<string> 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<ScriptActionContainer> ls = new List<ScriptActionContainer>();
|
|
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<ScriptAction> cmdList = new List<ScriptAction>();
|
|
/* 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<ContainerReference> scriptOffsets = new List<ContainerReference>(); //uint OFFSET, int Function/Script/Action ID
|
|
List<ContainerReference> functionOffsets = new List<ContainerReference>();
|
|
List<ContainerReference> actionOffsets = new List<ContainerReference>();
|
|
|
|
List<ScriptReference> refList = new List<ScriptReference>();
|
|
|
|
/* 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<ScriptCommandContainer> useScriptCallers = new List<ScriptCommandContainer>();
|
|
|
|
/* 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<byte[]> 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<byte[]> 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<uint> undeclaredFuncs = new SortedSet<uint>();
|
|
SortedSet<uint> undeclaredActions = new SortedSet<uint>();
|
|
|
|
SortedSet<uint> uninvokedFuncs = new SortedSet<uint>(allFunctions.Select(x => x.manualUserID).ToArray());
|
|
SortedSet<uint> unreferencedActions = new SortedSet<uint>(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<ScriptReference> refList, SortedSet<uint> 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);
|
|
}
|
|
}
|
|
}
|
|
}
|