├── Images ├── bars.png ├── save.png ├── trash.png ├── FolderIcon.png ├── SOR4Explorer.ico ├── SOR4Explorer.png ├── SheetIconSmall.png └── FolderIconSmall.png ├── TODO ├── .gitignore ├── Src ├── TextureInfo.cs ├── Extensions │ ├── BufferedListView.cs │ ├── BufferedTreeView.cs │ └── GraphicExtensions.cs ├── Data.cs ├── TextureList.cs ├── DraggableImages.cs ├── Program.cs ├── Forms │ ├── ImagePreviewForm.cs │ ├── DataViewForm.cs │ └── ExplorerForm.cs ├── DataLibrary.cs ├── Settings.cs ├── ContextMenu.cs ├── Controls │ └── BlackToolStrip.cs ├── TextureLoader.cs ├── TextureLibrary.cs └── DataLoader.cs ├── SOR4Explorer.csproj ├── LICENSE ├── SOR4Explorer.sln └── README.md /Images/bars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/bars.png -------------------------------------------------------------------------------- /Images/save.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/save.png -------------------------------------------------------------------------------- /Images/trash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/trash.png -------------------------------------------------------------------------------- /Images/FolderIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/FolderIcon.png -------------------------------------------------------------------------------- /Images/SOR4Explorer.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/SOR4Explorer.ico -------------------------------------------------------------------------------- /Images/SOR4Explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/SOR4Explorer.png -------------------------------------------------------------------------------- /Images/SheetIconSmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/SheetIconSmall.png -------------------------------------------------------------------------------- /Images/FolderIconSmall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jlcebrian/SOR4Explorer/HEAD/Images/FolderIconSmall.png -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | 2 | - Allow dragging a folder containing multiple textures to change 3 | - Better way to show which textures have been changed (in folder, too?) 4 | - Close the datafiles after 1 second of inactivity (allow playing while app is running) 5 | 6 | GAME DATA 7 | - Show related SpriteData (tooltip?) 8 | 9 | FUTURE STUFF 10 | - Install MODs from ZIPs (replace every texture in the zip file) 11 | - Show a 'mods' form with additional information about every mod 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.*~ 3 | project.lock.json 4 | .DS_Store 5 | *.pyc 6 | nupkg/ 7 | 8 | # Visual Studio Code 9 | .vscode 10 | 11 | # Rider 12 | .idea 13 | 14 | # User-specific files 15 | *.suo 16 | *.user 17 | *.userosscache 18 | *.sln.docstates 19 | 20 | # Build results 21 | [Dd]ebug/ 22 | [Dd]ebugPublic/ 23 | [Rr]elease/ 24 | [Rr]eleases/ 25 | x64/ 26 | x86/ 27 | build/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Oo]ut/ 32 | msbuild.log 33 | msbuild.err 34 | msbuild.wrn 35 | 36 | # Visual Studio 2015 37 | .vs/ 38 | 39 | -------------------------------------------------------------------------------- /Src/TextureInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing; 3 | 4 | namespace SOR4Explorer 5 | { 6 | public class TextureInfo 7 | { 8 | public string name; 9 | public UInt32 offset; 10 | public UInt32 flags; 11 | public UInt32 length; 12 | public string datafile; 13 | public bool changed; 14 | public bool original = true; 15 | } 16 | 17 | struct ImageOpProgress 18 | { 19 | public int processed; 20 | public int count; 21 | } 22 | 23 | struct ImageChange 24 | { 25 | public string datafile; 26 | public string path; 27 | public Bitmap image; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Src/Extensions/BufferedListView.cs: -------------------------------------------------------------------------------- 1 | using System.Windows.Forms; 2 | 3 | class BufferedListView : System.Windows.Forms.ListView 4 | { 5 | public BufferedListView() 6 | { 7 | // Activate double buffering 8 | SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); 9 | 10 | // Enable the OnNotifyMessage event so we get a chance to filter out 11 | // Windows messages before they get to the form's WndProc 12 | SetStyle(ControlStyles.EnableNotifyMessage, true); 13 | } 14 | 15 | protected override void OnNotifyMessage(Message m) 16 | { 17 | // Filter out the WM_ERASEBKGND message 18 | if (m.Msg != 0x14) 19 | { 20 | base.OnNotifyMessage(m); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /Src/Extensions/BufferedTreeView.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows.Forms; 3 | using System.Runtime.InteropServices; 4 | 5 | class BufferedTreeView : TreeView 6 | { 7 | protected override void OnHandleCreated(EventArgs e) 8 | { 9 | SendMessage(this.Handle, TVM_SETEXTENDEDSTYLE, (IntPtr)TVS_EX_DOUBLEBUFFER, (IntPtr)TVS_EX_DOUBLEBUFFER); 10 | base.OnHandleCreated(e); 11 | } 12 | 13 | // Pinvoke: 14 | private const int TVM_SETEXTENDEDSTYLE = 0x1100 + 44; 15 | private const int TVM_GETEXTENDEDSTYLE = 0x1100 + 45; 16 | private const int TVS_EX_DOUBLEBUFFER = 0x0004; 17 | private const int TVS_NOHSCROLL = 0x8000; 18 | 19 | [DllImport("user32.dll")] 20 | private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wp, IntPtr lp); 21 | } -------------------------------------------------------------------------------- /SOR4Explorer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | WinExe 5 | netcoreapp3.1 6 | true 7 | Images\SOR4Explorer.ico 8 | win10-x64 9 | true 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 José Luis Cebrián Pagüe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /SOR4Explorer.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30011.22 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SOR4Explorer", "SOR4Explorer.csproj", "{9D547811-0E2E-45D1-9BB8-A95633119B82}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {9D547811-0E2E-45D1-9BB8-A95633119B82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {9D547811-0E2E-45D1-9BB8-A95633119B82}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {9D547811-0E2E-45D1-9BB8-A95633119B82}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {9D547811-0E2E-45D1-9BB8-A95633119B82}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {6B5DBC56-6D46-49FD-B107-D3DC2A03081B} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /Src/Data.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace SOR4Explorer 6 | { 7 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] 8 | public class SerializationID : Attribute 9 | { 10 | public int id; 11 | public SerializationID(int id) => this.id = id; 12 | } 13 | 14 | // TODO: Use a dictionary for fast access 15 | public class LocalizationData 16 | { 17 | public struct Translation 18 | { 19 | public string key; 20 | public string text; 21 | } 22 | 23 | public struct Language 24 | { 25 | public string code; 26 | public Translation[] translations; 27 | } 28 | 29 | public Language[] languages; 30 | } 31 | 32 | public class SpriteData 33 | { 34 | public struct Size 35 | { 36 | public int width; 37 | public int height; 38 | } 39 | 40 | public struct Rect 41 | { 42 | public int x; 43 | public int y; 44 | public int width; 45 | public int height; 46 | } 47 | 48 | public class Part 49 | { 50 | public string name; 51 | public Rect bounds; 52 | public Rect frame; 53 | } 54 | 55 | public Size bounds; 56 | public Part[] parts; 57 | public bool unused; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SOR4Explorer 2 | Simple modding utility for Streets of Rage 4 3 | 4 | This small utility shows a file navigator and allows you 5 | to browse the game's textures, copy them to the clipboard, 6 | and export them as PNG. 7 | 8 | Eventually, it will also allow you to replace textures, 9 | but at the moment it's unclear if the game would load 10 | additional texture files or the replacements need to be 11 | written inside the existing big files, which would be 12 | pretty cumbersome and slow. 13 | 14 | ## Installation 15 | 16 | This program uses the .Net Core 5 preview. Get it from 17 | 18 | https://dotnet.microsoft.com/download/dotnet/5.0 19 | 20 | and then 21 | 22 | dotnet run 23 | 24 | ## Usage 25 | 26 | * Drop the SOR4 installation folder (which will contain 27 | just a 'data' and a 'x64' subfolder) into the window. 28 | * You can double click an image and export/copy it 29 | from the image's context menu, drag them to a folder, etc. 30 | * In order to export textures in bulk, right click a folder 31 | in the left tree and choose 'Save as...' (warning: slow!) 32 | 33 | 34 | ## Trivia 35 | 36 | The game seems to be very data-driven. 37 | 38 | The file named 'bigfile' contains a serialization of custom 39 | .Net objects including data for all the characters, moves, 40 | levels and a lot of other stuff. If this file is correctly 41 | reverse-engineered, there is a chance modders would 42 | eventually be able to add new character or moves to the 43 | game, instead of just making texture swaps. 44 | -------------------------------------------------------------------------------- /Src/TextureList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Text; 6 | 7 | namespace SOR4Explorer 8 | { 9 | /// 10 | /// Parses a texture file list from the SOR4 folder. The texture list is a binary database with 11 | /// the full path, offset and compressed length of every texture inside the related data file. 12 | /// 13 | class TextureList : IEnumerable 14 | { 15 | public object this[int index] => items[index]; 16 | public int Length => items.Count; 17 | public IEnumerator GetEnumerator() => items.GetEnumerator(); 18 | IEnumerator IEnumerable.GetEnumerator() => items.GetEnumerator(); 19 | 20 | private readonly List items = new List(); 21 | 22 | public void Add(TextureInfo item) 23 | { 24 | items.Add(item); 25 | } 26 | 27 | public TextureList(string filename, string datafile) 28 | { 29 | long originalFileSize = Settings.GetFileSize(datafile); 30 | 31 | var file = File.OpenRead(filename); 32 | var reader = new BinaryReader(file, Encoding.Unicode); 33 | try 34 | { 35 | while (true) 36 | { 37 | var info = new TextureInfo() 38 | { 39 | name = reader.ReadString().Replace('/', Path.DirectorySeparatorChar), 40 | offset = reader.ReadUInt32(), 41 | flags = reader.ReadUInt32(), 42 | length = reader.ReadUInt32(), 43 | datafile = datafile 44 | }; 45 | if (info.offset >= originalFileSize) 46 | info.original = false; 47 | items.Add(info); 48 | } 49 | } 50 | catch (EndOfStreamException) 51 | { 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Src/DraggableImages.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing.Imaging; 3 | using System.IO; 4 | using System.Windows.Forms; 5 | 6 | namespace SOR4Explorer 7 | { 8 | class DraggableImages : IDataObject 9 | { 10 | private readonly TextureLibrary library; 11 | private readonly TextureInfo[] images; 12 | private string[] temporaryFiles; 13 | 14 | public DraggableImages(TextureLibrary library, TextureInfo[] images) 15 | { 16 | this.library = library; 17 | this.images = images; 18 | } 19 | 20 | public object GetData(string format, bool autoConvert) => GetData(format); 21 | public object GetData(Type format) => GetData(format.ToString()); 22 | public object GetData(string format) 23 | { 24 | if (temporaryFiles == null) 25 | { 26 | temporaryFiles = new string[images.Length]; 27 | for (int n = 0; n < images.Length; n++) 28 | { 29 | var name = Path.ChangeExtension(Path.GetFileName(images[n].name), ".png"); 30 | var path = Path.Combine(Path.GetTempPath(), name); 31 | var image = library.LoadTexture(images[n]); 32 | image.Save(path, ImageFormat.Png); 33 | temporaryFiles[n] = path; 34 | } 35 | } 36 | return temporaryFiles; 37 | } 38 | 39 | public bool GetDataPresent(string format, bool autoConvert) => GetDataPresent(format); 40 | public bool GetDataPresent(string format) => format == DataFormats.FileDrop || format == "SOR4Explorer"; 41 | public bool GetDataPresent(Type format) => false; 42 | 43 | public string[] GetFormats(bool autoConvert) => GetFormats(); 44 | public string[] GetFormats() => new string[] { DataFormats.FileDrop }; 45 | 46 | public void SetData(string format, bool autoConvert, object data) { } 47 | public void SetData(string format, object data) { } 48 | public void SetData(Type format, object data) { } 49 | public void SetData(object data) { } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Src/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Linq; 5 | using System.Net.NetworkInformation; 6 | using System.Threading.Tasks; 7 | using System.Windows.Forms; 8 | 9 | namespace SOR4Explorer 10 | { 11 | static class Program 12 | { 13 | public static Icon Icon { get; private set; } 14 | public static Image FolderIcon { get; private set; } 15 | public static Image FolderIconSmall { get; private set; } 16 | public static Image BarsImage { get; private set; } 17 | public static Image SaveImage { get; private set; } 18 | public static Image TrashImage { get; private set; } 19 | public static Image SheetIconSmall { get; private set; } 20 | 21 | static void LoadImages() 22 | { 23 | var assembly = typeof(Program).Assembly; 24 | Icon = new Icon(assembly.GetManifestResourceStream("SOR4Explorer.Images.SOR4Explorer.ico")); 25 | FolderIcon = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.FolderIcon.png")); 26 | FolderIconSmall = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.FolderIconSmall.png")); 27 | SheetIconSmall = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.SheetIconSmall.png")); 28 | BarsImage = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.bars.png")); 29 | SaveImage = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.save.png")); 30 | TrashImage = Image.FromStream(assembly.GetManifestResourceStream("SOR4Explorer.Images.trash.png")); 31 | } 32 | 33 | /// 34 | /// The main entry point for the application. 35 | /// 36 | [STAThread] 37 | static void Main() 38 | { 39 | Application.SetHighDpiMode(HighDpiMode.SystemAware); 40 | Application.EnableVisualStyles(); 41 | Application.SetCompatibleTextRenderingDefault(false); 42 | 43 | LoadImages(); 44 | 45 | Application.Run(new ExplorerForm()); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Src/Forms/ImagePreviewForm.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Data; 5 | using System.Drawing; 6 | using System.Drawing.Drawing2D; 7 | using System.Drawing.Imaging; 8 | using System.IO; 9 | using System.Linq; 10 | using System.Runtime.InteropServices; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | using System.Windows.Forms; 14 | 15 | namespace SOR4Explorer 16 | { 17 | public partial class ImagePreviewForm : Form 18 | { 19 | public ImagePreviewForm(Bitmap image, string name) 20 | { 21 | Screen screen = Screen.FromControl(this); 22 | int maxWidth = screen.WorkingArea.Width; 23 | int maxHeight = screen.WorkingArea.Height; 24 | 25 | DoubleBuffered = true; 26 | StartPosition = FormStartPosition.Manual; 27 | FormBorderStyle = FormBorderStyle.Fixed3D; 28 | MaximizeBox = false; 29 | MinimizeBox = false; 30 | Text = name; 31 | 32 | ClientSize = image.Size; 33 | while (ClientSize.Width < 400 || ClientSize.Height < 400) 34 | { 35 | if (ClientSize.Width > maxWidth / 2 || ClientSize.Height > maxHeight / 2) 36 | break; 37 | ClientSize *= 2; 38 | } 39 | Location = Cursor.Position - Size/2; 40 | Location = new Point( 41 | Math.Clamp(Location.X, screen.WorkingArea.X, Math.Max(screen.WorkingArea.X, screen.WorkingArea.Right - Width)), 42 | Math.Clamp(Location.Y, screen.WorkingArea.Y, Math.Max(screen.WorkingArea.Y, screen.WorkingArea.Bottom - Height)) 43 | ); 44 | 45 | Paint += (sender, ev) => 46 | { 47 | ev.Graphics.InterpolationMode = InterpolationMode.NearestNeighbor; 48 | ev.Graphics.PixelOffsetMode = PixelOffsetMode.Half; 49 | ev.Graphics.DrawImage(image, ClientRectangle, 50 | new Rectangle(0, 0, image.Width, image.Height), GraphicsUnit.Pixel); 51 | }; 52 | MouseClick += (sender, ev) => 53 | { 54 | switch (ev.Button) 55 | { 56 | case MouseButtons.Left: 57 | Close(); 58 | break; 59 | case MouseButtons.Right: 60 | ContextMenuStrip buttonMenu = ContextMenu.FromImage(name, image); 61 | buttonMenu.Show(this, ev.Location); 62 | break; 63 | } 64 | }; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Src/Extensions/GraphicExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing; 3 | using System.Drawing.Drawing2D; 4 | 5 | static class GraphicExtensions 6 | { 7 | private static GraphicsPath GetCapsule(RectangleF baseRect) 8 | { 9 | float diameter; 10 | RectangleF arc; 11 | GraphicsPath path = new GraphicsPath(); 12 | try 13 | { 14 | if (baseRect.Width > baseRect.Height) 15 | { 16 | diameter = baseRect.Height; 17 | SizeF sizeF = new SizeF(diameter, diameter); 18 | arc = new RectangleF(baseRect.Location, sizeF); 19 | path.AddArc(arc, 90, 180); 20 | arc.X = baseRect.Right - diameter; 21 | path.AddArc(arc, 270, 180); 22 | } 23 | else if (baseRect.Width < baseRect.Height) 24 | { 25 | diameter = baseRect.Width; 26 | SizeF sizeF = new SizeF(diameter, diameter); 27 | arc = new RectangleF(baseRect.Location, sizeF); 28 | path.AddArc(arc, 180, 180); 29 | arc.Y = baseRect.Bottom - diameter; 30 | path.AddArc(arc, 0, 180); 31 | } 32 | else 33 | { 34 | path.AddEllipse(baseRect); 35 | } 36 | } 37 | catch (Exception) 38 | { 39 | path.AddEllipse(baseRect); 40 | } 41 | finally 42 | { 43 | path.CloseFigure(); 44 | } 45 | return path; 46 | } 47 | 48 | private static GraphicsPath GetRoundedRect(RectangleF baseRect, float radius) 49 | { 50 | if (radius <= 0.0F) 51 | { 52 | GraphicsPath mPath = new GraphicsPath(); 53 | mPath.AddRectangle(baseRect); 54 | mPath.CloseFigure(); 55 | return mPath; 56 | } 57 | if (radius >= (Math.Min(baseRect.Width, baseRect.Height)) / 2.0) 58 | return GetCapsule(baseRect); 59 | 60 | float diameter = radius * 2.0F; 61 | SizeF sizeF = new SizeF(diameter, diameter); 62 | RectangleF arc = new RectangleF(baseRect.Location, sizeF); 63 | GraphicsPath path = new System.Drawing.Drawing2D.GraphicsPath(); 64 | 65 | path.AddArc(arc, 180, 90); 66 | arc.X = baseRect.Right - diameter; 67 | path.AddArc(arc, 270, 90); 68 | arc.Y = baseRect.Bottom - diameter; 69 | path.AddArc(arc, 0, 90); 70 | arc.X = baseRect.Left; 71 | path.AddArc(arc, 90, 90); 72 | path.CloseFigure(); 73 | return path; 74 | } 75 | 76 | public static void DrawRoundedRectangle(this Graphics graphics, Pen pen, 77 | Rectangle rectangle, float radius) 78 | { 79 | GraphicsPath path = GetRoundedRect(rectangle, radius); 80 | graphics.DrawPath(pen, path); 81 | } 82 | 83 | public static void DrawRoundedRectangle(this Graphics graphics, Pen pen, 84 | float x, float y, float width, float height, float radius) 85 | { 86 | RectangleF rectangle = new RectangleF(x, y, width, height); 87 | GraphicsPath path = GetRoundedRect(rectangle, radius); 88 | graphics.DrawPath(pen, path); 89 | } 90 | 91 | public static void DrawRoundedRectangle(this Graphics graphics, Pen pen, 92 | int x, int y, int width, int height, int radius) 93 | { 94 | float fx = Convert.ToSingle(x); 95 | float fy = Convert.ToSingle(y); 96 | float fwidth = Convert.ToSingle(width); 97 | float fheight = Convert.ToSingle(height); 98 | float fradius = Convert.ToSingle(radius); 99 | graphics.DrawRoundedRectangle(pen, fx, fy, fwidth, fheight, fradius); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Src/DataLibrary.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Linq; 7 | using System.Reflection; 8 | using System.Text; 9 | 10 | namespace SOR4Explorer 11 | { 12 | class DataLibrary 13 | { 14 | private readonly Dictionary> objects = new Dictionary>(); 15 | 16 | public class Object 17 | { 18 | public string Name; 19 | public string ClassName; 20 | public byte[] Data; 21 | } 22 | 23 | public IEnumerable ClassNames => objects.Keys; 24 | public IEnumerable ObjectNames(string className) => objects[className].Select(n => n.Value.Name); 25 | 26 | public bool Load(string installationPath) 27 | { 28 | objects.Clear(); 29 | 30 | try 31 | { 32 | using var file = File.OpenRead(Path.Combine(installationPath, "data/bigfile")); 33 | using var stream = new DeflateStream(file, CompressionMode.Decompress); 34 | using var reader = new BinaryReader(stream, Encoding.Unicode); 35 | 36 | var signature = reader.ReadInt32(); 37 | while (true) 38 | { 39 | var className = reader.ReadString(); 40 | var objectName = reader.ReadString().Replace('/', Path.DirectorySeparatorChar); 41 | var length = reader.ReadInt32(); 42 | var data = reader.ReadBytes(length); 43 | AddObject(new Object() 44 | { 45 | Name = objectName, 46 | ClassName = className, 47 | Data = data 48 | }); 49 | } 50 | } 51 | catch (EndOfStreamException) 52 | { 53 | return true; 54 | } 55 | catch (Exception) 56 | { 57 | return false; 58 | } 59 | 60 | void AddObject(Object obj) 61 | { 62 | if (objects.ContainsKey(obj.ClassName) == false) 63 | objects[obj.ClassName] = new Dictionary() { { obj.Name, obj } }; 64 | else 65 | objects[obj.ClassName][obj.Name] = obj; 66 | } 67 | } 68 | 69 | public PackedData UnPack(string className, string objectName) 70 | { 71 | objectName = objectName.Replace('/', Path.DirectorySeparatorChar); 72 | if (objects.ContainsKey(className) == false) 73 | return default; 74 | if (objects[className].ContainsKey(objectName) == false) 75 | return default; 76 | 77 | var data = objects[className][objectName].Data; 78 | return new PackedData 79 | { 80 | ClassName = className, 81 | ObjectName = objectName, 82 | Properties = DataLoader.Unpack(data.AsSpan(), null), 83 | }; 84 | } 85 | 86 | public T Unserialize(string name) where T : class, new() 87 | { 88 | var className = typeof(T).Name; 89 | var objectName = name.Replace('/', Path.DirectorySeparatorChar); 90 | if (objects.ContainsKey(className) == false) 91 | return default; 92 | if (objects[className].ContainsKey(objectName) == false) 93 | return default; 94 | var data = objects[className][objectName].Data; 95 | return DataLoader.Unserialize(typeof(T), data.AsSpan()) as T; 96 | } 97 | 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Src/Settings.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Win32; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.Drawing; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Text.Json.Serialization; 11 | using System.Threading.Tasks; 12 | 13 | namespace SOR4Explorer 14 | { 15 | class Settings 16 | { 17 | public static Settings Instance => instance ?? new Settings(); 18 | private static Settings instance; 19 | 20 | public static string InstallationPath 21 | { 22 | get => Instance.variables.InstallationPath; 23 | set => Instance.variables.InstallationPath = value; 24 | } 25 | public static string LocalDataPath => Instance.localDataPath; 26 | 27 | public static void SetFileSize(string name, long size) 28 | { 29 | Instance.variables.SetFileSize(name, size); 30 | } 31 | 32 | public static long GetFileSize(string name) 33 | { 34 | return Instance.variables.GetFileSize(name); 35 | } 36 | 37 | public static bool FileExists(string name) 38 | { 39 | return File.Exists(FileName(name)); 40 | } 41 | 42 | public static string FileName(string name) 43 | { 44 | return Path.Combine(Instance.localDataPath, name); 45 | } 46 | 47 | #region Implementation 48 | 49 | [Serializable] 50 | class Variables : INotifyPropertyChanged 51 | { 52 | private string installationPath; 53 | public string InstallationPath 54 | { 55 | get => installationPath; 56 | set 57 | { 58 | if (installationPath != value) 59 | { 60 | installationPath = value; 61 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("InstallationPath")); 62 | } 63 | } 64 | } 65 | 66 | public Dictionary FileSizes { get; set; } = new Dictionary(); 67 | 68 | public long GetFileSize(string name) => FileSizes.TryGetValue(name, out long size) ? size : 0; 69 | public void SetFileSize(string name, long size) 70 | { 71 | FileSizes[name] = size; 72 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("FileSize")); 73 | } 74 | 75 | public event PropertyChangedEventHandler PropertyChanged; 76 | } 77 | 78 | private readonly string localDataPath; 79 | private Variables variables; 80 | 81 | private Settings() 82 | { 83 | instance = this; 84 | localDataPath = Path.Combine(Environment.GetEnvironmentVariable("LocalAppData"), "SOR4 Explorer"); 85 | Directory.CreateDirectory(localDataPath); 86 | 87 | var settingsFile = Path.Combine(localDataPath, SettingsFileName); 88 | if (File.Exists(settingsFile)) 89 | { 90 | var text = File.ReadAllText(settingsFile); 91 | variables = JsonSerializer.Deserialize(text); 92 | } 93 | else 94 | { 95 | variables = new Variables(); 96 | } 97 | variables.PropertyChanged += (sender, ev) => 98 | { 99 | var text = JsonSerializer.Serialize(variables, new JsonSerializerOptions() { 100 | WriteIndented = true 101 | }); 102 | File.WriteAllText(settingsFile, text); 103 | }; 104 | } 105 | 106 | private const string SettingsFileName = "settings.json"; 107 | 108 | #endregion 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Src/ContextMenu.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Drawing.Imaging; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Windows.Forms; 8 | 9 | namespace SOR4Explorer 10 | { 11 | static class ContextMenu 12 | { 13 | private static void LoadTexture(TextureLibrary library, TextureInfo info) 14 | { 15 | OpenFileDialog ofd = new OpenFileDialog 16 | { 17 | Filter = "Images|*.png;*.bmp;*.jpg", 18 | }; 19 | if (ofd.ShowDialog() == DialogResult.OK) 20 | { 21 | Bitmap bitmap; 22 | try 23 | { 24 | bitmap = new Bitmap(ofd.FileName); 25 | } 26 | catch (Exception exception) 27 | { 28 | MessageBox.Show( 29 | $"Unable to open image {ofd.FileName}\n{exception.Message}", 30 | "Invalid image", 31 | MessageBoxButtons.OK, 32 | MessageBoxIcon.Error, 33 | MessageBoxDefaultButton.Button1); 34 | return; 35 | } 36 | library.AddChange(info.name, bitmap); 37 | } 38 | } 39 | 40 | private static void SaveTexture(string name, Bitmap image) 41 | { 42 | SaveFileDialog sfd = new SaveFileDialog 43 | { 44 | FileName = Path.GetFileName(name) + ".png", 45 | Filter = "Images|*.png;*.bmp;*.jpg", 46 | OverwritePrompt = true 47 | }; 48 | if (sfd.ShowDialog() == DialogResult.OK) 49 | { 50 | ImageFormat format = Path.GetExtension(sfd.FileName) switch 51 | { 52 | ".jpg" => ImageFormat.Jpeg, 53 | ".bmp" => ImageFormat.Bmp, 54 | _ => ImageFormat.Png 55 | }; 56 | image.Save(sfd.FileName, format); 57 | } 58 | } 59 | 60 | public static void SaveTextures(TextureLibrary library, IEnumerable files, bool useBaseFolder = false, IProgress progress = null) 61 | { 62 | var fbd = new FolderBrowserDialog() 63 | { 64 | RootFolder = Environment.SpecialFolder.Desktop, 65 | UseDescriptionForTitle = true, 66 | Description = "Destination folder" 67 | }; 68 | if (fbd.ShowDialog() == DialogResult.OK) 69 | library.SaveTextures(fbd.SelectedPath, files, useBaseFolder, progress); 70 | } 71 | 72 | public static ContextMenuStrip FromImage(TextureLibrary library, TextureInfo info) 73 | { 74 | ContextMenuStrip menu = new ContextMenuStrip(); 75 | menu.Items.Add("&Replace with...", null, (sender, ev) => LoadTexture(library, info)); 76 | if (library.ImageChanges.Any(n => n.Key == info.name)) 77 | menu.Items.Add("Discard changes", null, (sender, ev) => library.DiscardChange(info)); 78 | menu.Items.Add(new ToolStripSeparator()); 79 | menu.Items.Add("&Copy", null, (sender, ev) => Clipboard.SetImage(library.LoadTexture(info))); 80 | menu.Items.Add("&Save as...", null, (sender, ev) => SaveTexture(info.name, library.LoadTexture(info))); 81 | return menu; 82 | } 83 | 84 | public static ContextMenuStrip FromImage(string name, Bitmap image) 85 | { 86 | ContextMenuStrip menu = new ContextMenuStrip(); 87 | menu.Items.Add("&Copy", null, (sender, ev) => Clipboard.SetImage(image)); 88 | menu.Items.Add("&Save as...", null, (sender, ev) => SaveTexture(name, image)); 89 | return menu; 90 | } 91 | 92 | public static ContextMenuStrip FromImages(TextureLibrary library, IEnumerable info, 93 | bool useBaseFolder = false, IProgress progress = null) 94 | { 95 | ContextMenuStrip menu = new ContextMenuStrip(); 96 | menu.Items.Add($"&Save {info.Count()} images as...", null, 97 | (sender, ev) => SaveTextures(library, info, useBaseFolder, progress)); 98 | return menu; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Src/Controls/BlackToolStrip.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Text; 5 | using System.Windows.Forms; 6 | 7 | namespace SOR4Explorer 8 | { 9 | class BlackToolStrip : ToolStrip 10 | { 11 | public ToolStripItemAlignment NextAlignment = ToolStripItemAlignment.Left; 12 | 13 | public BlackToolStrip() 14 | { 15 | AutoSize = false; 16 | Stretch = true; 17 | Dock = DockStyle.Top; 18 | ForeColor = Color.White; 19 | BackColor = Color.Black; 20 | Size = new Size(400, 60); 21 | Padding = new Padding(0); 22 | GripStyle = ToolStripGripStyle.Hidden; 23 | Renderer = new ToolStripCustomRenderer(); 24 | CanOverflow = false; 25 | } 26 | 27 | public ToolStripLabel AddLabel(string text = "") 28 | { 29 | var label = new ToolStripLabel() 30 | { 31 | Alignment = NextAlignment, 32 | TextAlign = ContentAlignment.MiddleRight, 33 | AutoSize = true, 34 | Size = new Size(400, 10), 35 | Margin = new Padding(0, 0, 32, 0), 36 | Text = text, 37 | }; 38 | Items.Add(label); 39 | return label; 40 | } 41 | 42 | public ToolStripProgressBar AddProgressBar() 43 | { 44 | var progressBar = new ToolStripProgressBar() 45 | { 46 | Alignment = NextAlignment, 47 | Size = new Size(200, 8), 48 | Padding = new Padding(0, 0, 40, 0), 49 | }; 50 | Items.Add(progressBar); 51 | return progressBar; 52 | } 53 | 54 | public ToolStripButton AddButton(Image image, string label = "", Action action = null) 55 | { 56 | var button = new ToolStripButton(label, image, (o, s) => action?.Invoke()) 57 | { 58 | Alignment = NextAlignment, 59 | AutoToolTip = false, 60 | Padding = new Padding(16), 61 | ImageAlign = ContentAlignment.MiddleRight, 62 | }; 63 | Items.Add(button); 64 | return button; 65 | } 66 | 67 | public ToolStripMenuItem AddMenuItem(Image image, string label = "", Action action = null) 68 | { 69 | var item = new ToolStripMenuItem(label, image, (o, s) => action?.Invoke()) 70 | { 71 | Alignment = NextAlignment, 72 | Padding = new Padding(16), 73 | }; 74 | Items.Add(item); 75 | return item; 76 | } 77 | } 78 | 79 | class ToolStripColorTable : ProfessionalColorTable 80 | { 81 | static readonly Color greyBackground = Color.FromArgb(48, 48, 48); 82 | 83 | public override Color ToolStripBorder => Color.Black; 84 | 85 | public override Color ButtonSelectedGradientBegin => greyBackground; 86 | public override Color ButtonSelectedGradientEnd => greyBackground; 87 | public override Color ButtonSelectedHighlight => greyBackground; 88 | public override Color ButtonSelectedBorder => greyBackground; 89 | 90 | public override Color MenuItemPressedGradientBegin => greyBackground; 91 | public override Color MenuItemPressedGradientEnd => greyBackground; 92 | public override Color MenuItemSelectedGradientBegin => SystemColors.Highlight; 93 | public override Color MenuItemSelectedGradientEnd => SystemColors.Highlight; 94 | public override Color MenuItemSelected => greyBackground; 95 | public override Color MenuItemBorder => greyBackground; 96 | 97 | public override Color ToolStripDropDownBackground => greyBackground; 98 | public override Color ImageMarginGradientBegin => greyBackground; 99 | public override Color ImageMarginGradientMiddle => greyBackground; 100 | public override Color ImageMarginGradientEnd => greyBackground; 101 | } 102 | 103 | class ToolStripCustomRenderer : ToolStripProfessionalRenderer 104 | { 105 | public ToolStripCustomRenderer() : base(new ToolStripColorTable() { UseSystemColors = false }) 106 | { 107 | } 108 | 109 | protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e) 110 | { 111 | e.TextColor = Color.White; 112 | base.OnRenderItemText(e); 113 | } 114 | 115 | protected override void OnRenderButtonBackground(ToolStripItemRenderEventArgs e) 116 | { 117 | base.OnRenderButtonBackground(e); 118 | if (e.Item.Pressed) 119 | { 120 | Rectangle rc = new Rectangle(Point.Empty, e.Item.Size); 121 | Color c = SystemColors.Highlight; 122 | using SolidBrush brush = new SolidBrush(c); 123 | e.Graphics.FillRectangle(brush, rc); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Src/TextureLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Drawing; 3 | using System.Drawing.Imaging; 4 | using System.IO; 5 | using System.IO.Compression; 6 | using System.Runtime.InteropServices; 7 | using System.Text; 8 | 9 | namespace SOR4Explorer 10 | { 11 | /// 12 | /// This class contains the logic needed to compress and uncompress data file textures. 13 | /// Textures are compressed by a headerless ZLib stream (using CLR) and then stored 14 | /// in XNB format. Although the XNB format is complex, fortunately all the SOR4 textures 15 | /// are stored very simply: they are all an uncompressed canonical ARGB32 format 16 | /// with no mipmaps or any other fancy features. 17 | /// 18 | static class TextureLoader 19 | { 20 | const string SerializationClass = "Microsoft.Xna.Framework.Content.Texture2DReader"; 21 | 22 | public static byte[] Compress(Bitmap image) 23 | { 24 | int width = image.Width; 25 | int height = image.Height; 26 | 27 | MemoryStream output = new MemoryStream(); 28 | BinaryWriter writer = new BinaryWriter(output); 29 | 30 | writer.Write(Encoding.ASCII.GetBytes("XNBw")); // Signature 31 | writer.Write((byte)5); // Version code 32 | writer.Write((byte)0); 33 | writer.Write((UInt32)0); // File size placeholder 34 | writer.Write((byte)1); // Item count 35 | writer.Write(SerializationClass); 36 | writer.Write((UInt32)0); // Always 0 37 | writer.Write((UInt16)256); // Always 256 38 | writer.Write((UInt32)0); // Texture format (0 = canonical) 39 | writer.Write((UInt32)image.Width); 40 | writer.Write((UInt32)image.Height); 41 | writer.Write((UInt32)1); // Mipmap count 42 | writer.Write((UInt32)image.Width*image.Height*4); // Uncompressed size 43 | 44 | var imageData = image.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); 45 | byte[] line = new byte[width * 4]; 46 | for (int y = 0; y < height; y++) 47 | { 48 | Marshal.Copy(imageData.Scan0 + imageData.Stride * y, line, 0, width * 4); 49 | for (int x = 0; x < width * 4; x += 4) 50 | { 51 | byte b = line[x + 0]; 52 | line[x + 0] = line[x + 2]; 53 | line[x + 2] = b; 54 | 55 | // Premultiply alpha values 56 | line[x + 0] = (byte)(line[x + 0] * line[x + 3] / 255); 57 | line[x + 1] = (byte)(line[x + 1] * line[x + 3] / 255); 58 | line[x + 2] = (byte)(line[x + 2] * line[x + 3] / 255); 59 | } 60 | writer.Write(line); 61 | } 62 | image.UnlockBits(imageData); 63 | 64 | // Update the file size 65 | var fileSize = output.Position; 66 | output.Seek(6, SeekOrigin.Begin); 67 | writer.Write((UInt32)fileSize); 68 | output.Position = 0; 69 | 70 | // Compress the texture 71 | MemoryStream compressedOutput = new MemoryStream(); 72 | var compressionStream = new DeflateStream(compressedOutput, CompressionMode.Compress); 73 | output.CopyTo(compressionStream); 74 | compressionStream.Flush(); 75 | 76 | return compressedOutput.ToArray(); 77 | } 78 | 79 | public static Bitmap Load(TextureInfo textureInfo, byte[] data) 80 | { 81 | var filename = Path.GetFileName(textureInfo.name); 82 | var output = new MemoryStream(); 83 | output.Seek(0, SeekOrigin.Begin); 84 | output.SetLength(0); 85 | var stream = new DeflateStream(new MemoryStream(data, false), CompressionMode.Decompress); 86 | stream.CopyTo(output); 87 | var uncompressedSize = output.Position; 88 | output.Seek(0, SeekOrigin.Begin); 89 | var reader = new BinaryReader(output); 90 | 91 | // Check signature 92 | var signature = Encoding.UTF8.GetString(reader.ReadBytes(4)); 93 | if (signature != "XNBw") 94 | { 95 | Console.WriteLine($"{filename} has an unknown signature: {signature}"); 96 | return null; 97 | } 98 | 99 | // Check version number 100 | int versionHi = reader.ReadByte(); 101 | int versionLo = reader.ReadByte(); 102 | if (versionHi != 5 || versionLo != 0) 103 | { 104 | Console.WriteLine($"{filename} has an unknown version code: {versionHi}.{versionLo}"); 105 | return null; 106 | } 107 | 108 | // Check file size 109 | uint fileSize = reader.ReadUInt32(); 110 | if (fileSize != uncompressedSize) 111 | { 112 | Console.WriteLine($"{filename} has the wrong length {fileSize} encoded, should be {uncompressedSize}"); 113 | return null; 114 | } 115 | 116 | // Check items count 117 | byte itemsCount = reader.ReadByte(); 118 | if (itemsCount == 0) 119 | { 120 | Console.WriteLine($"{filename} is empty"); 121 | return null; 122 | } 123 | if (itemsCount != 1) 124 | { 125 | Console.WriteLine($"{filename} contains more than one item"); 126 | } 127 | 128 | // Check serialization class name 129 | string classname = reader.ReadString(); 130 | if (classname != SerializationClass) 131 | { 132 | Console.WriteLine($"{filename} uses an unknown class {classname}"); 133 | return null; 134 | } 135 | var unknown0 = reader.ReadUInt32(); // Always 0 136 | var unknown1 = reader.ReadUInt16(); // Always 256 137 | 138 | // Read texture header, check format 139 | uint format = reader.ReadUInt32(); 140 | int width = reader.ReadInt32(); 141 | int height = reader.ReadInt32(); 142 | int levelCount = reader.ReadInt32(); // Level count (for mipmaps) 143 | int dataSize = reader.ReadInt32(); // Uncompressed data size 144 | if (format != 0) 145 | { 146 | Console.WriteLine($"{filename} uses an unsupported format {format}"); 147 | return null; 148 | } 149 | if (dataSize != width*height*4) 150 | { 151 | Console.WriteLine($"{filename} has a wrong data size ({dataSize} instead of {width * height * 4})"); 152 | return null; 153 | } 154 | 155 | // Load texture data, convert Argb to Abgr for Bitmap() 156 | var image = new Bitmap(width, height); 157 | var bmpData = image.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, PixelFormat.Format32bppArgb); 158 | IntPtr ptr = bmpData.Scan0; 159 | var line = new byte[width * 4]; 160 | for (int y = 0; y < height; y++) 161 | { 162 | reader.Read(line, 0, width * 4); 163 | for (int x = 0; x < width * 4; x += 4) 164 | { 165 | byte b = line[x + 0]; 166 | line[x + 0] = line[x + 2]; 167 | line[x + 2] = b; 168 | if (line[x + 3] < line[x] || line[x + 3] < line[x + 1] || line[x + 3] < line[x + 2]) 169 | Console.WriteLine($"{filename} has non-premultiplied alpha!"); 170 | } 171 | Marshal.Copy(line, 0, ptr, width * 4); 172 | ptr += bmpData.Stride; 173 | } 174 | image.UnlockBits(bmpData); 175 | return image; 176 | } 177 | 178 | public static Bitmap ScaledImage(Bitmap image) 179 | { 180 | var scaled = new Bitmap(200, 200); 181 | using (Graphics g = Graphics.FromImage(scaled)) 182 | { 183 | if (image.Width <= 200 && image.Height <= 200) 184 | { 185 | g.DrawImage(image, 100 - image.Width / 2, 100 - image.Height / 2); 186 | } 187 | else if (image.Width >= image.Height) 188 | { 189 | float scaledHeight = 200 * image.Height / image.Width; 190 | g.DrawImage(image, 0, 100 - scaledHeight / 2, 200, scaledHeight); 191 | } 192 | else 193 | { 194 | float scaledWidth = 200 * image.Width / image.Height; 195 | g.DrawImage(image, 100 - scaledWidth / 2, 0, scaledWidth, 200); 196 | } 197 | } 198 | return scaled; 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /Src/TextureLibrary.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Drawing; 4 | using System.Drawing.Imaging; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Security.Policy; 8 | using System.Text; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | 12 | namespace SOR4Explorer 13 | { 14 | class TextureLibrary 15 | { 16 | 17 | public string RootPath { get; set; } 18 | 19 | public event Action OnTextureChangeDiscarded; 20 | public event Action OnTextureChanged; 21 | 22 | public readonly Dictionary Lists = new Dictionary(); 23 | public readonly Dictionary DataFiles = new Dictionary(); 24 | public readonly HashSet Folders = new HashSet(); 25 | public readonly Dictionary ImageChanges = new Dictionary(); 26 | 27 | private readonly Dictionary ImageCountCache = new Dictionary(); 28 | 29 | private string dataFolder; 30 | private int nonOriginalCount; 31 | 32 | public int NonOriginalCount => nonOriginalCount; 33 | 34 | public void AddChange (string path, Bitmap image) 35 | { 36 | foreach (var list in Lists.Values) 37 | { 38 | var item = list.FirstOrDefault(n => n.name == path); 39 | if (item != null) 40 | { 41 | ImageChanges[path] = new ImageChange() 42 | { 43 | datafile = item.datafile, 44 | image = image, 45 | path = path 46 | }; 47 | item.changed = true; 48 | OnTextureChanged?.Invoke(item, image); 49 | return; 50 | } 51 | } 52 | 53 | Console.Write($"Warning: attempt to change file {path} which is not in the library"); 54 | } 55 | 56 | public void DiscardChange(TextureInfo info) 57 | { 58 | if (ImageChanges.ContainsKey(info.name)) 59 | { 60 | ImageChanges.Remove(info.name); 61 | info.changed = false; 62 | OnTextureChangeDiscarded?.Invoke(info); 63 | } 64 | } 65 | 66 | public void DiscardChanges() 67 | { 68 | foreach (var list in Lists.Values) 69 | { 70 | foreach (var item in list) 71 | { 72 | item.changed = false; 73 | OnTextureChangeDiscarded?.Invoke(item); 74 | } 75 | } 76 | ImageChanges.Clear(); 77 | } 78 | 79 | public void Clear() 80 | { 81 | Lists.Clear(); 82 | DataFiles.Clear(); 83 | Folders.Clear(); 84 | ImageCountCache.Clear(); 85 | nonOriginalCount = 0; 86 | RootPath = ""; 87 | } 88 | 89 | public void CloseDatafiles() 90 | { 91 | foreach (var key in DataFiles.Keys.ToArray()) 92 | { 93 | if (DataFiles[key] != null) 94 | { 95 | DataFiles[key].Close(); 96 | DataFiles[key] = null; 97 | } 98 | } 99 | } 100 | 101 | public bool GameFilesChanged() 102 | { 103 | foreach (var texturesFilePath in DataFiles.Keys) 104 | { 105 | long size = Settings.GetFileSize(texturesFilePath); 106 | 107 | bool changed; 108 | if (DataFiles[texturesFilePath] != null) 109 | changed = DataFiles[texturesFilePath].Length != size; 110 | else 111 | changed = new FileInfo(texturesFilePath).Length != size; 112 | 113 | if (changed) 114 | return true; 115 | } 116 | return false; 117 | } 118 | 119 | public void RestoreFromBackups() 120 | { 121 | CloseDatafiles(); 122 | 123 | foreach (var texturesFilePath in DataFiles.Keys) 124 | { 125 | string tableFilePath = Path.Combine(Path.GetDirectoryName(texturesFilePath), Path.GetFileName(texturesFilePath).Replace("textures", "texture_table")); 126 | string backupTablePath = Path.Combine(Settings.FileName(Path.ChangeExtension(Path.GetFileName(tableFilePath), ".bak"))); 127 | if (File.Exists(backupTablePath)) 128 | { 129 | File.Copy(backupTablePath, tableFilePath, true); 130 | long size = Settings.GetFileSize(texturesFilePath); 131 | if (size != 0) 132 | { 133 | using FileStream file = File.OpenWrite(texturesFilePath); 134 | file.SetLength(size); 135 | 136 | var list = Lists[texturesFilePath]; 137 | var originalList = new TextureList(tableFilePath, texturesFilePath); 138 | foreach (var info in list) 139 | { 140 | if (info.offset >= size) 141 | { 142 | var previous = originalList.First(n => n.name == info.name); 143 | info.offset = previous.offset; 144 | info.length = previous.length; 145 | OnTextureChangeDiscarded?.Invoke(info); 146 | } 147 | } 148 | } 149 | } 150 | } 151 | 152 | nonOriginalCount = 0; 153 | } 154 | 155 | private void SaveBackups() 156 | { 157 | foreach (var dataFilePath in DataFiles.Keys) 158 | { 159 | string tableFilePath = Path.Combine(Path.GetDirectoryName(dataFilePath), Path.GetFileName(dataFilePath).Replace("textures", "texture_table")); 160 | string backupTablePath = Path.Combine(Settings.FileName(Path.ChangeExtension(Path.GetFileName(tableFilePath), ".bak"))); 161 | if (!File.Exists(backupTablePath)) 162 | { 163 | File.Copy(tableFilePath, backupTablePath); 164 | long size = new FileInfo(dataFilePath).Length; 165 | Settings.SetFileSize(dataFilePath, size); 166 | } 167 | } 168 | } 169 | 170 | public void SaveChanges() 171 | { 172 | CloseDatafiles(); 173 | 174 | // Replaces textures by adding them to the end of the original file. This 175 | // should make it possible to go back to the original library by truncating 176 | // the data file and restoring the original texture list. 177 | 178 | foreach (var entry in Lists) 179 | { 180 | var fileName = entry.Key.Replace("textures", "texture_table"); 181 | var list = entry.Value; 182 | 183 | FileStream tableFile = File.Open(fileName, FileMode.Create); 184 | var tableWriter = new BinaryWriter(tableFile, Encoding.Unicode); 185 | foreach (var item in list) 186 | { 187 | if (item.changed && ImageChanges.TryGetValue(item.name, out ImageChange change)) 188 | { 189 | var compressedData = TextureLoader.Compress(change.image); 190 | FileStream dataFileStream = File.OpenWrite(entry.Key); 191 | dataFileStream.Seek(0, SeekOrigin.End); 192 | item.offset = (uint)dataFileStream.Position; 193 | item.length = (uint)compressedData.Length; 194 | item.changed = false; 195 | if (item.original) 196 | { 197 | item.original = false; 198 | nonOriginalCount++; 199 | } 200 | dataFileStream.Write(compressedData); 201 | dataFileStream.Close(); 202 | } 203 | 204 | tableWriter.Write(item.name.Replace(Path.DirectorySeparatorChar, '/')); 205 | tableWriter.Write((UInt32)item.offset); 206 | tableWriter.Write((UInt32)0); 207 | tableWriter.Write((UInt32)item.length); 208 | } 209 | tableWriter.Close(); 210 | tableFile.Close(); 211 | } 212 | 213 | ImageChanges.Clear(); 214 | } 215 | 216 | public bool Load(string installationPath) 217 | { 218 | Clear(); 219 | 220 | RootPath = installationPath; 221 | 222 | try 223 | { 224 | int fileIndex = 1; 225 | dataFolder = Path.Combine(installationPath, "data"); 226 | 227 | string texturesFile = Path.Combine(dataFolder, "textures"); 228 | string tableFile = Path.Combine(dataFolder, "texture_table"); 229 | while (File.Exists(texturesFile) && File.Exists(tableFile)) 230 | { 231 | DataFiles[texturesFile] = File.OpenRead(texturesFile); 232 | Lists[texturesFile] = new TextureList(tableFile, texturesFile); 233 | 234 | foreach (var file in Lists[texturesFile]) 235 | { 236 | AddFoldersInPath(file.name); 237 | if (file.original == false) 238 | nonOriginalCount++; 239 | } 240 | 241 | fileIndex++; 242 | texturesFile = Path.Combine(dataFolder, $"textures{fileIndex:D2}"); 243 | tableFile = Path.Combine(dataFolder, $"texture_table{fileIndex:D2}"); 244 | } 245 | } 246 | catch (Exception) 247 | { 248 | Lists.Clear(); 249 | DataFiles.Clear(); 250 | return false; 251 | } 252 | 253 | SaveBackups(); 254 | return true; 255 | 256 | void AddFoldersInPath(string path) 257 | { 258 | var directory = Path.GetDirectoryName(path); 259 | if (directory != "") 260 | { 261 | Folders.Add(directory); 262 | AddFoldersInPath(directory); 263 | } 264 | } 265 | } 266 | 267 | public byte[] LoadTextureData(TextureInfo textureInfo) 268 | { 269 | if (textureInfo.datafile == null) 270 | return null; 271 | 272 | byte[] data = new byte[textureInfo.length]; 273 | var datafile = DataFiles[textureInfo.datafile]; 274 | if (datafile == null) 275 | datafile = DataFiles[textureInfo.datafile] = File.OpenRead(textureInfo.datafile); 276 | lock (datafile) // Not needed? 277 | { 278 | datafile.Seek(textureInfo.offset, SeekOrigin.Begin); 279 | datafile.Read(data, 0, (int)textureInfo.length); 280 | } 281 | return data; 282 | } 283 | 284 | public Bitmap LoadTexture(TextureInfo textureInfo) 285 | { 286 | if (textureInfo.datafile == null) 287 | return ImageChanges.TryGetValue(textureInfo.name, out ImageChange change) ? change.image : null; 288 | 289 | return TextureLoader.Load(textureInfo, LoadTextureData(textureInfo)); 290 | } 291 | 292 | public void SaveTextures(string destination, IEnumerable files, bool useBaseFolder, IProgress progress) 293 | { 294 | string basePath = GetCommonPath(files.Select(n => n.name)); 295 | if (useBaseFolder && basePath != null) 296 | basePath = Path.GetDirectoryName(basePath); 297 | Task.Run(() => 298 | { 299 | var op = new ImageOpProgress() { count = files.Count() }; 300 | Thread.CurrentThread.Priority = ThreadPriority.BelowNormal; 301 | Parallel.ForEach(files, 302 | new ParallelOptions() { MaxDegreeOfParallelism = Environment.ProcessorCount - 1 }, 303 | info => 304 | { 305 | try 306 | { 307 | var image = LoadTexture(info); 308 | if (image != null) 309 | { 310 | var path = basePath?.Length > 0 ? Path.GetRelativePath(basePath, info.name) : info.name; 311 | var destinationPath = Path.Combine(destination, path); 312 | Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); 313 | image.Save(Path.ChangeExtension(destinationPath, ".png"), ImageFormat.Png); 314 | } 315 | Interlocked.Increment(ref op.processed); 316 | if (progress != null) 317 | progress.Report(op); 318 | } 319 | catch (Exception) 320 | { 321 | } 322 | }); 323 | }); 324 | 325 | static string GetCommonPath(IEnumerable paths) 326 | { 327 | string path = paths.Aggregate((a, b) => a.Length > b.Length ? a : b); 328 | while (path != "" && paths.All(n => n.StartsWith(path)) == false) 329 | path = Path.GetDirectoryName(path); 330 | return path; 331 | } 332 | } 333 | 334 | public List GetTextures(string folder) 335 | { 336 | List result = new List(); 337 | folder = NormalizePath(folder); 338 | foreach (var list in Lists.Values) 339 | result.AddRange(list.Where(item => item.name.StartsWith(folder) && !item.changed)); 340 | return result; 341 | } 342 | 343 | public bool Contains(string path) 344 | { 345 | foreach (var list in Lists.Values) 346 | { 347 | if (list.Any(n => n.name == path)) 348 | return true; 349 | } 350 | return false; 351 | } 352 | 353 | public int CountTextures() 354 | { 355 | if (ImageCountCache.ContainsKey("")) 356 | return ImageCountCache[""]; 357 | 358 | return ImageCountCache[""] = Lists.Values.Aggregate(0, (result, item) => result += item.Length); 359 | } 360 | 361 | public int CountTextures(string folder) 362 | { 363 | folder = NormalizePath(folder); 364 | if (ImageCountCache.ContainsKey(folder)) 365 | return ImageCountCache[folder]; 366 | return ImageCountCache[folder] = Lists.Values.Aggregate(0, (result, list) => result += list.Count(item => item.name.StartsWith(folder))); 367 | } 368 | 369 | private static string NormalizePath(string folder) 370 | { 371 | if (folder != "") 372 | folder = Path.TrimEndingDirectorySeparator(folder) + Path.DirectorySeparatorChar; 373 | return folder; 374 | } 375 | 376 | public IEnumerable GetSubfolders(string folder) 377 | { 378 | folder = NormalizePath(folder); 379 | return Folders.Where(n => n.StartsWith(folder)) 380 | .Select(n => n.Substring(folder.Length)) 381 | .Where(n => !n.Contains('\\')); 382 | } 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /Src/DataLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Text; 6 | 7 | namespace SOR4Explorer 8 | { 9 | /// 10 | /// Provides a convenient view into the game's packed data files 11 | /// 12 | public class PackedData 13 | { 14 | public enum Type 15 | { 16 | String, // string 17 | Integer, // int 18 | Float, // float 19 | Object, // Property 20 | StringArray, // string[] 21 | IntegerArray, // int[] 22 | FloatArray, // float[] 23 | ObjectArray, // Property[] 24 | } 25 | 26 | public class Descriptor 27 | { 28 | public int id; 29 | public Descriptor parent; 30 | 31 | public override string ToString() 32 | { 33 | return parent == null ? $"#{id}" : $"{parent}.{id}"; 34 | } 35 | } 36 | 37 | public class Property 38 | { 39 | public Descriptor Descriptor; 40 | public Type Type; 41 | public object Value; 42 | } 43 | 44 | public string ClassName; 45 | public string ObjectName; 46 | public Property[] Properties; 47 | } 48 | 49 | /// 50 | /// This class contains methods to unpack serialized 51 | /// data from the game's 'bigfile' storage 52 | /// 53 | static class DataLoader 54 | { 55 | /// 56 | /// Unpack a packed binary stream into a set of description objects contained 57 | /// inside the 'PackedData' class. This should be called only with valid streams 58 | /// (top level are always valid; if the stream is inside a property, 59 | /// IsValidContainer should be called to ensure no errors). 60 | /// 61 | /// An array of Property[] objects 62 | public static PackedData.Property[] Unpack(ReadOnlySpan data, PackedData.Descriptor descriptor) 63 | { 64 | var children = new Dictionary(); 65 | 66 | int index = 0; 67 | while (index < data.Length) 68 | { 69 | int chunk = ReadInt(data, ref index); 70 | int fieldID = (chunk >> 3); 71 | int encoding = (chunk & 0x7); 72 | var childDescriptor = new PackedData.Descriptor { id = fieldID, parent = descriptor }; 73 | switch (encoding) 74 | { 75 | case 0: // Integer 76 | { 77 | int value = ReadInt(data, ref index); 78 | AddItem(children, fieldID, PackedData.Type.Integer, childDescriptor, value); 79 | break; 80 | } 81 | case 2: // Struct or string 82 | { 83 | var length = ReadInt(data, ref index); 84 | var slice = data.Slice(index, length); 85 | index += length; 86 | 87 | // Force string interpretation if we already have objets 88 | bool isString = IsString(slice) || !IsValidContainer(slice) || length == 0; 89 | if (children.ContainsKey(fieldID) && children[fieldID].Type == PackedData.Type.StringArray) 90 | isString = true; 91 | 92 | if (isString) // String 93 | { 94 | // Detect internal string encoding. Actually, this could be a case of 95 | // trying to deserialize a string[] instead of a string. Further tests needed! 96 | if (slice.Length > 1 && slice[0] == 10) 97 | { 98 | int subindex = 1; 99 | while (ReadInt(slice, ref subindex) == slice.Length - subindex && subindex <= slice.Length) 100 | { 101 | slice = slice.Slice(subindex); 102 | if (slice.Length < 2 || slice[0] != 10) 103 | break; 104 | subindex = 1; 105 | } 106 | } 107 | var value = Encoding.UTF8.GetString(slice); 108 | AddItem(children, fieldID, PackedData.Type.String, childDescriptor, value); 109 | } 110 | else 111 | { 112 | var value = Unpack(slice, childDescriptor); 113 | AddItem(children, fieldID, PackedData.Type.Object, childDescriptor, value); 114 | } 115 | break; 116 | } 117 | case 5: // Float 118 | { 119 | var value = BitConverter.ToSingle(data.Slice(index, 4)); 120 | index += 4; 121 | 122 | AddItem(children, fieldID, PackedData.Type.Float, childDescriptor, value); 123 | break; 124 | } 125 | } 126 | } 127 | return children.Values.ToArray(); 128 | 129 | static void AddItem(Dictionary children, int fieldID, 130 | PackedData.Type type, PackedData.Descriptor childDescriptor, object value) 131 | { 132 | if (children.ContainsKey(fieldID) == false) 133 | { 134 | children[fieldID] = new PackedData.Property() 135 | { 136 | Descriptor = childDescriptor, 137 | Type = type, 138 | Value = value 139 | }; 140 | } 141 | else if (children[fieldID].Type == type) 142 | { 143 | var array = Array.CreateInstance(ArrayType(type), 2); 144 | array.SetValue(children[fieldID].Value, 0); 145 | array.SetValue(value, 1); 146 | children[fieldID] = new PackedData.Property() 147 | { 148 | Descriptor = childDescriptor, 149 | Type = ConvertToArray(type), 150 | Value = array 151 | }; 152 | } 153 | else if (children[fieldID].Type == ConvertToArray(type)) 154 | { 155 | // TODO: This is expensive. We should preprocess the data stream 156 | // beforehand to know how many array elements must be allocated in advance. 157 | 158 | var previous = (Array)children[fieldID].Value; 159 | var next = Array.CreateInstance(ArrayType(type), previous.Length + 1); 160 | previous.CopyTo(next, 0); 161 | next.SetValue(value, next.Length - 1); 162 | children[fieldID] = new PackedData.Property() 163 | { 164 | Descriptor = childDescriptor, 165 | Type = ConvertToArray(type), 166 | Value = next 167 | }; 168 | } 169 | else 170 | { 171 | throw new Exception($"Inconsistent data types ({type} in a {children[fieldID].Type}) for {childDescriptor}"); 172 | } 173 | } 174 | } 175 | 176 | /// 177 | /// Deserialization helper. Given a packed data stream, produces 178 | /// an object by filling its public properties. Use the 179 | /// SerializationID attribute to assign proper IDs to the 180 | /// public fields (the declaration order will be used otherwise). 181 | /// 182 | public static object Unserialize(Type T, ReadOnlySpan data) 183 | { 184 | var fields = T.GetFields(); 185 | 186 | FieldInfo GetField(int id) 187 | { 188 | foreach (var field in fields) 189 | { 190 | var idAttribute = field.GetCustomAttribute(); 191 | if (idAttribute != null && idAttribute.id == id) 192 | return field; 193 | } 194 | if (id >= 1 && id <= fields.Length) 195 | return fields[id - 1]; 196 | else 197 | return null; 198 | } 199 | 200 | object result = Activator.CreateInstance(T); 201 | 202 | int index = 0; 203 | while (index < data.Length) 204 | { 205 | int chunk = ReadInt(data, ref index); 206 | int fieldID = (chunk >> 3); 207 | int encoding = (chunk & 0x7); 208 | 209 | if (encoding == 5) // Float encoding 210 | { 211 | var value = BitConverter.ToSingle(data.Slice(index, 4)); 212 | index += 4; 213 | GetField(fieldID)?.SetValue(result, value); 214 | } 215 | else if (encoding == 0) // Integer encoding (compressed) 216 | { 217 | var value = ReadInt(data, ref index); 218 | var field = GetField(fieldID); 219 | if (field != null) 220 | { 221 | if (field.FieldType == typeof(bool)) 222 | field.SetValue(result, value != 0); 223 | else 224 | field.SetValue(result, value); 225 | } 226 | } 227 | else if (encoding == 2) // Object encoding 228 | { 229 | var length = ReadInt(data, ref index); 230 | var slice = data.Slice(index, length); 231 | index += length; 232 | 233 | if (T == typeof(string)) 234 | return Encoding.UTF8.GetString(slice); 235 | 236 | var field = GetField(fieldID); 237 | if (field != null) 238 | { 239 | if (field.FieldType == typeof(string)) 240 | { 241 | // Detect internal string encoding. Actually, this could be a case of 242 | // trying to deserialize a string[] instead of a string. Further tests needed! 243 | if (slice.Length > 0 && slice[0] == 10) 244 | { 245 | int subindex = 1; 246 | if (ReadInt(slice, ref subindex) == slice.Length - subindex) 247 | { 248 | field.SetValue(result, Encoding.UTF8.GetString(slice.Slice(subindex))); 249 | continue; 250 | } 251 | } 252 | string value = Encoding.UTF8.GetString(slice); 253 | field.SetValue(result, value); 254 | } 255 | else if (field.FieldType.IsArray) 256 | { 257 | var value = Unserialize(field.FieldType.GetElementType(), slice); 258 | Array previousArray = (Array)field.GetValue(result); 259 | if (previousArray == null) 260 | { 261 | previousArray = Array.CreateInstance(field.FieldType.GetElementType(), 1); 262 | previousArray.SetValue(value, 0); 263 | field.SetValue(result, previousArray); 264 | } 265 | else 266 | { 267 | Array newArray = Array.CreateInstance(field.FieldType.GetElementType(), previousArray.Length + 1); 268 | Array.Copy(previousArray, newArray, previousArray.Length); 269 | newArray.SetValue(value, previousArray.Length - 1); 270 | field.SetValue(result, newArray); 271 | } 272 | } 273 | else 274 | { 275 | field.SetValue(result, Unserialize(field.FieldType, slice)); 276 | } 277 | } 278 | } 279 | else 280 | { 281 | throw new Exception("Invalid encoding"); 282 | } 283 | } 284 | return result; 285 | } 286 | 287 | /// 288 | /// A simple heuristic to identify strings which could otherwise be 289 | /// considered valid binary containers. Unfortunately, the storage 290 | /// format does not discriminate between strings and containers. 291 | /// 292 | /// True if the data looks like a string 293 | static bool IsString(ReadOnlySpan data) 294 | { 295 | bool containsBinary = false; 296 | foreach (var c in data) 297 | if (c > 127 || (c < 32 && c != 10 && c != 13)) 298 | containsBinary = true; 299 | return !containsBinary; 300 | } 301 | 302 | /// 303 | /// Check if the given data stream contains a valid packed container. 304 | /// This only helps to discriminate: if this returns false, the container 305 | /// *is* a string. If it returns true, it still *may* be a string. 306 | /// 307 | /// 308 | /// True if the data can be parsed as a packed container 309 | static bool IsValidContainer(ReadOnlySpan data) 310 | { 311 | int index = 0; 312 | while (index < data.Length) 313 | { 314 | int chunk = ReadInt(data, ref index); 315 | int fieldID = (chunk >> 3); 316 | int encoding = (chunk & 0x7); 317 | if (fieldID > 256 || (encoding != 0 && encoding != 2 && encoding != 5)) 318 | return false; 319 | if (encoding == 0) // Integer 320 | { 321 | ReadInt(data, ref index); 322 | } 323 | else if (encoding == 2) // Container 324 | { 325 | int length = ReadInt(data, ref index); 326 | if (length < 0) 327 | return false; 328 | 329 | // Note that the container data may not be valid (strings) 330 | index += length; 331 | } 332 | else // Float 333 | { 334 | index += 4; 335 | } 336 | } 337 | return (index == data.Length); 338 | } 339 | 340 | static int ReadInt(ReadOnlySpan data, ref int index) 341 | { 342 | int rot = 0; 343 | int total = 0; 344 | int value; 345 | if (index >= data.Length || index < 0) 346 | return 0; 347 | 348 | do 349 | { 350 | value = data[index++]; 351 | total |= (value & 0x7F) << rot; 352 | rot += 7; 353 | } 354 | while ((value & 0x80) != 0 && index < data.Length); 355 | return total; 356 | } 357 | 358 | static PackedData.Type ConvertToArray(PackedData.Type type) 359 | { 360 | return type switch 361 | { 362 | PackedData.Type.Float => PackedData.Type.FloatArray, 363 | PackedData.Type.Integer => PackedData.Type.IntegerArray, 364 | PackedData.Type.String => PackedData.Type.StringArray, 365 | PackedData.Type.Object => PackedData.Type.ObjectArray, 366 | _ => throw new Exception("Invalid type") 367 | }; 368 | } 369 | 370 | static System.Type ArrayType(PackedData.Type type) 371 | { 372 | return type switch 373 | { 374 | PackedData.Type.Float => typeof(float), 375 | PackedData.Type.Integer => typeof(int), 376 | PackedData.Type.String => typeof(string), 377 | PackedData.Type.Object => typeof(PackedData.Property[]), 378 | _ => throw new Exception("Invalid type") 379 | }; 380 | } 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /Src/Forms/DataViewForm.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.CodeAnalysis; 4 | using System.Drawing; 5 | using System.IO; 6 | using System.Linq; 7 | using System.Net.NetworkInformation; 8 | using System.Windows.Forms; 9 | 10 | namespace SOR4Explorer 11 | { 12 | class DataViewForm : Form 13 | { 14 | private readonly DataLibrary data; 15 | private SplitContainer splitContainer; 16 | 17 | class TreeSorterByImageIndex : System.Collections.IComparer 18 | { 19 | public int Compare(object ay, object ax) 20 | { 21 | return ax is TreeNode x && ay is TreeNode y ? 22 | x.ImageIndex == y.ImageIndex ? x.Text.CompareTo(y.Text) : x.ImageIndex.CompareTo(y.ImageIndex) : 0; 23 | } 24 | } 25 | 26 | public static Dictionary PropertyCuts = new Dictionary() 27 | { 28 | { "GuiNodeData.6.2.6", "GuiNodeData.6" }, 29 | { "BtNodeData.7.1.5.1", "BtNodeData.7.1" }, 30 | { "BtNodeData.7.1.10.1", "BtNodeData.7.1" }, 31 | { "BtNodeData.7.1.25.1", "BtNodeData.7.1" }, 32 | { "BtNodeData.7.1.29.1", "BtNodeData.7.1" }, 33 | }; 34 | 35 | public static Dictionary PropertyNames = new Dictionary() 36 | { 37 | { "MetaFont.1", "Base Font" }, 38 | { "MetaFont.1.1", "Name" }, 39 | { "MetaFont.7", "Font Variants" }, 40 | { "MetaFont.7.1", "Languages" }, 41 | { "MetaFont.7.2", "Texture" }, 42 | { "SpriteData.1", "Size" }, 43 | { "SpriteData.1.1", "Width" }, 44 | { "SpriteData.1.2", "Height" }, 45 | { "SpriteData.2", "Sprite" }, 46 | { "SpriteData.2.1", "Texture" }, 47 | { "SpriteData.2.2", "OuterBounds" }, 48 | { "SpriteData.2.2.1", "X" }, 49 | { "SpriteData.2.2.2", "Y" }, 50 | { "SpriteData.2.2.3", "Width" }, 51 | { "SpriteData.2.2.4", "Height" }, 52 | { "SpriteData.2.3", "InnerBounds" }, 53 | { "SpriteData.2.3.1", "X" }, 54 | { "SpriteData.2.3.2", "Y" }, 55 | { "SpriteData.2.3.3", "Width" }, 56 | { "SpriteData.2.3.4", "Height" }, 57 | { "LocalizationData.1", "Localization" }, 58 | { "LocalizationData.1.1", "Language" }, 59 | { "LocalizationData.1.2", "Strings" }, 60 | { "LocalizationData.1.2.1", "Key" }, 61 | { "LocalizationData.1.2.2", "Value" }, 62 | { "GuiNodeData.6", "Contents" }, 63 | { "GuiNodeData.6.1", "Size" }, 64 | { "GuiNodeData.6.1.1", "Width" }, 65 | { "GuiNodeData.6.1.2", "Height" }, 66 | { "GuiNodeData.6.2", "Contents" }, 67 | { "GuiNodeData.6.2.1", "Position" }, 68 | { "GuiNodeData.6.2.1.1", "X" }, 69 | { "GuiNodeData.6.2.1.2", "Y" }, 70 | { "GuiNodeData.6.2.2", "Name" }, 71 | { "GuiNodeData.6.2.5", "Color" }, 72 | { "GuiNodeData.6.2.5.1", "R" }, 73 | { "GuiNodeData.6.2.5.2", "G" }, 74 | { "GuiNodeData.6.2.5.3", "B" }, 75 | { "GuiNodeData.6.2.5.4", "A" }, 76 | { "GuiNodeData.6.2.7", "Image" }, 77 | { "GuiNodeData.6.2.7.1", "Texture" }, 78 | { "GuiNodeData.6.2.8", "Label" }, 79 | { "GuiNodeData.6.2.8.1", "Text" }, 80 | { "GuiNodeData.6.2.8.2", "Font" }, 81 | { "GuiNodeData.6.2.14", "Rectangle" }, 82 | { "GuiNodeData.6.2.14.2", "Size" }, 83 | { "GuiNodeData.6.2.14.2.1", "Width" }, 84 | { "GuiNodeData.6.2.14.2.2", "Height" }, 85 | { "ExtrasScreenData.1", "Galleries" }, 86 | { "ExtrasScreenData.1.1", "Name" }, 87 | { "ExtrasScreenData.1.2", "Info" }, 88 | { "ExtrasScreenData.1.3", "Textures" }, 89 | { "ExtrasScreenData.2", "Bios" }, 90 | { "ExtrasScreenData.2.1", "Sprites" }, 91 | { "ExtrasScreenData.2.2", "Portrait" }, 92 | { "ExtrasScreenData.2.3", "Icon" }, 93 | { "ExtrasScreenData.2.4", "IconUnselected" }, 94 | { "ExtrasScreenData.2.5", "TextName" }, 95 | { "ExtrasScreenData.2.6", "Occupation" }, 96 | { "ExtrasScreenData.2.7", "Style" }, 97 | { "ExtrasScreenData.2.8", "Hobby" }, 98 | { "ExtrasScreenData.2.9", "Moves" }, 99 | { "ExtrasScreenData.2.10", "Text" }, 100 | { "ExtrasScreenData.2.11", "Power" }, 101 | { "ExtrasScreenData.2.12", "Technique" }, 102 | { "ExtrasScreenData.2.13", "Speed" }, 103 | { "ExtrasScreenData.2.14", "Jump" }, 104 | { "ExtrasScreenData.2.15", "Stamina" }, 105 | { "AnimatedSpriteData.1", "Animations" }, 106 | { "AnimatedSpriteData.1.1", "Name" }, 107 | { "AnimatedSpriteData.1.3", "Frames" }, 108 | { "AnimatedSpriteData.1.3.1", "Sprites" }, 109 | { "AnimatedSpriteData.1.3.1.1", "Texture" }, 110 | { "CharacterData.99", "Attacks" }, 111 | { "CharacterData.99.1", "Name" }, 112 | }; 113 | 114 | #region Initialization & components 115 | 116 | private TreeView objectsTreeView; 117 | private BlackToolStrip toolStrip; 118 | private TreeView infoTreeView; 119 | 120 | public DataViewForm(DataLibrary data) 121 | { 122 | this.data = data; 123 | InitializeComponents(); 124 | } 125 | 126 | void InitializeComponents() 127 | { 128 | SuspendLayout(); 129 | 130 | // 131 | // toolStrip 132 | // 133 | toolStrip = new BlackToolStrip(); 134 | toolStrip.AddButton(Program.BarsImage); 135 | toolStrip.NextAlignment = ToolStripItemAlignment.Right; 136 | 137 | // 138 | // splitterContainer 139 | // 140 | splitContainer = new SplitContainer() 141 | { 142 | BackColor = SystemColors.Control, 143 | Dock = DockStyle.Fill, 144 | Location = new Point(0, 0), 145 | Name = "splitContainer", 146 | FixedPanel = FixedPanel.Panel1, 147 | Size = new Size(1422, 1006), 148 | SplitterDistance = 650, 149 | TabIndex = 0, 150 | }; 151 | 152 | // 153 | // objectsList 154 | // 155 | objectsTreeView = new BufferedTreeView() 156 | { 157 | BackColor = SystemColors.Control, 158 | Dock = DockStyle.Fill, 159 | Font = new Font("Tahoma", 9.0F, FontStyle.Regular, GraphicsUnit.Point, ((byte)(0))), 160 | ItemHeight = 40, 161 | BorderStyle = BorderStyle.None, 162 | FullRowSelect = true, 163 | HideSelection = false, 164 | Sorted = true, 165 | TreeViewNodeSorter = new TreeSorterByImageIndex(), 166 | ImageList = new ImageList 167 | { 168 | ImageSize = new Size(32, 20), 169 | ColorDepth = ColorDepth.Depth32Bit 170 | } 171 | }; 172 | objectsTreeView.AfterSelect += ObjectsTreeView_AfterSelect; 173 | objectsTreeView.ImageList.Images.Add(Program.FolderIconSmall); 174 | objectsTreeView.ImageList.Images.Add(Program.SheetIconSmall); 175 | splitContainer.Panel1.Controls.Add(objectsTreeView); 176 | 177 | // 178 | // infoTreeView 179 | // 180 | infoTreeView = new BufferedTreeView() 181 | { 182 | BackColor = SystemColors.ControlDark, 183 | Dock = DockStyle.Fill, 184 | Font = new Font("Tahoma", 9.0F, FontStyle.Regular, GraphicsUnit.Point, ((byte)(0))), 185 | ItemHeight = 40, 186 | BorderStyle = BorderStyle.None, 187 | FullRowSelect = true, 188 | HideSelection = false, 189 | DrawMode = TreeViewDrawMode.OwnerDrawText, 190 | ImageList = new ImageList 191 | { 192 | ImageSize = new Size(32, 20), 193 | ColorDepth = ColorDepth.Depth32Bit 194 | } 195 | }; 196 | infoTreeView.DrawNode += InfoTreeView_DrawNode; 197 | infoTreeView.ImageList.Images.Add(Program.FolderIconSmall); 198 | infoTreeView.ImageList.Images.Add(Program.SheetIconSmall); 199 | splitContainer.Panel2.Controls.Add(infoTreeView); 200 | 201 | // 202 | // Finish 203 | // 204 | Size = new Size(1800, 1000); 205 | Icon = Program.Icon; 206 | Text = "Bigfile contents"; 207 | Controls.Add(splitContainer); 208 | 209 | ResumeLayout(); 210 | } 211 | 212 | private void InfoTreeView_DrawNode(object sender, DrawTreeNodeEventArgs e) 213 | { 214 | if (e.Bounds.Height == 0) 215 | return; 216 | 217 | var node = e.Node; 218 | var bounds = e.Bounds; 219 | var treeView = e.Node.TreeView; 220 | Font font = node.NodeFont ?? treeView.Font; 221 | var color = treeView.SelectedNode == node ? SystemColors.HighlightText : treeView.ForeColor; 222 | if (treeView.SelectedNode == node) 223 | e.Graphics.FillRectangle(treeView.Focused ? SystemBrushes.Highlight : Brushes.Gray, e.Bounds); 224 | 225 | bool isArrayMember = node.Nodes.Count > 0 && node.Text.All(n => Char.IsDigit(n)); 226 | if (node.Tag != null && (node.IsExpanded == false || !isArrayMember)) 227 | { 228 | var valueBounds = e.Bounds; 229 | valueBounds.X = Math.Max(400, e.Bounds.Right + 20); 230 | valueBounds.Width = (int)e.Graphics.ClipBounds.Right - valueBounds.X; 231 | TextRenderer.DrawText(e.Graphics, node.Tag.ToString(), font, valueBounds, 232 | treeView.ForeColor, TextFormatFlags.VerticalCenter); 233 | } 234 | if (node.Text.Contains('.')) 235 | { 236 | bounds.Width = (int)e.Graphics.ClipBounds.Right - bounds.X; 237 | TextRenderer.DrawText(e.Graphics, "?", new Font(font, FontStyle.Bold), 238 | new Rectangle(e.Bounds.Right, bounds.Y, bounds.Width, bounds.Height), treeView.ForeColor, 239 | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.VerticalCenter); 240 | color = Color.FromArgb(64, 64, 64); 241 | } 242 | TextRenderer.DrawText(e.Graphics, node.Text, font, bounds, color, 243 | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.VerticalCenter); 244 | } 245 | 246 | protected override bool ProcessCmdKey(ref Message msg, Keys keyData) 247 | { 248 | if (keyData == Keys.Escape) 249 | Hide(); 250 | 251 | return base.ProcessCmdKey(ref msg, keyData); 252 | } 253 | 254 | #endregion 255 | 256 | #region Filling object lists 257 | 258 | struct ObjectRef 259 | { 260 | public string Key; 261 | public string Value; 262 | } 263 | 264 | public void FillObjects() 265 | { 266 | objectsTreeView.Nodes.Clear(); 267 | 268 | List objects = new List(); 269 | foreach (var className in data.ClassNames.OrderBy(a => a)) 270 | { 271 | if (className.EndsWith("CacheData")) 272 | continue; 273 | objects.AddRange(data.ObjectNames(className).Select(n => new ObjectRef { Key = className, Value = n })); 274 | } 275 | 276 | AddItems(null, objects); 277 | 278 | void AddItems(TreeNode node, IEnumerable items, int prefix = 0) 279 | { 280 | var children = (node?.Nodes ?? objectsTreeView.Nodes); 281 | 282 | var folders = items 283 | .Where(n => n.Value.AsSpan(prefix).Contains(Path.DirectorySeparatorChar)) 284 | .Select(n => n.Value[prefix..n.Value.IndexOf(Path.DirectorySeparatorChar, prefix)]) 285 | .Distinct() 286 | .OrderBy(n => n); 287 | foreach (var folder in folders) 288 | { 289 | var child = children.Add(folder); 290 | var subprefix = items.First().Value.Substring(0, prefix) + folder + Path.DirectorySeparatorChar; 291 | AddItems(child, items.Where(n => n.Value.StartsWith(subprefix)).ToArray(), subprefix.Length); 292 | } 293 | 294 | var local = items 295 | .Where(n => n.Value.AsSpan(prefix + 1) 296 | .Contains(Path.DirectorySeparatorChar) == false); 297 | foreach (var item in local) 298 | { 299 | var child = children.Add($"{item.Key}.{item.Value}", Path.GetFileName(item.Value)); 300 | child.ImageIndex = child.SelectedImageIndex = 1; 301 | } 302 | 303 | if (node != null) 304 | node.ImageIndex = node.SelectedImageIndex = 0; 305 | } 306 | } 307 | 308 | private void ObjectsTreeView_AfterSelect(object sender, TreeViewEventArgs e) 309 | { 310 | Cursor.Current = Cursors.WaitCursor; 311 | infoTreeView.BeginUpdate(); 312 | infoTreeView.Nodes.Clear(); 313 | var node = e.Node; 314 | string prefix; 315 | 316 | if (node != null && node.Name.Contains('.')) 317 | { 318 | var dotIndex = node.Name.IndexOf('.'); 319 | var className = node.Name.Substring(0, dotIndex); 320 | var objectName = node.Name.Substring(dotIndex + 1); 321 | var view = data.UnPack(className, objectName); 322 | if (view != null) 323 | { 324 | prefix = $"{className}."; 325 | AddItems(null, view.Properties); 326 | } 327 | } 328 | 329 | string PropertyName(PackedData.Descriptor descriptor) 330 | { 331 | var name = descriptor.ToString().Replace("#", prefix); 332 | if (PropertyNames.TryGetValue(name, out string fullName)) 333 | return fullName; 334 | 335 | int replacements = 0; 336 | while (replacements < 100) 337 | { 338 | bool any = false; 339 | foreach (var pair in PropertyCuts) 340 | { 341 | if (name.StartsWith(pair.Key)) 342 | { 343 | name = pair.Value + name.Substring(pair.Key.Length); 344 | any = true; 345 | break; 346 | } 347 | } 348 | if (!any) 349 | break; 350 | if (PropertyNames.TryGetValue(name, out string fullName2)) 351 | return fullName2; 352 | replacements++; 353 | } 354 | return name; 355 | } 356 | 357 | string AddItems(TreeNode node, PackedData.Property[] items) 358 | { 359 | string lastValue = null; 360 | string keyValue = null; 361 | string altValue = null; 362 | 363 | foreach (var item in items) 364 | { 365 | var parent = (node?.Nodes ?? infoTreeView.Nodes).Add(PropertyName(item.Descriptor)); 366 | if (item.Value is PackedData.Property[][] array) 367 | { 368 | for (int n = 0; n < array.Length; n++) 369 | { 370 | var child = parent.Nodes.Add($"{n+1:N0}"); 371 | child.Tag = AddItems(child, array[n]); 372 | } 373 | } 374 | else if (item.Value is PackedData.Property[] children) 375 | { 376 | lastValue = AddItems(parent, children); 377 | } 378 | else if (item.Value is PackedData.Property prop) 379 | { 380 | lastValue = AddItems(parent, new PackedData.Property[] { prop }); 381 | } 382 | else if (item.Value is Array valueArray) 383 | { 384 | for (int n = 0; n < valueArray.Length; n++) 385 | { 386 | var child = parent.Nodes.Add($"{valueArray.GetValue(n)}"); 387 | child.ImageIndex = child.SelectedImageIndex = 1; 388 | } 389 | } 390 | else 391 | { 392 | parent.ImageIndex = parent.SelectedImageIndex = 1; 393 | 394 | var value = item.Value.ToString(); 395 | if (item.Value is string str && str == "") 396 | value = "(empty)"; 397 | 398 | parent.Tag = value; 399 | lastValue = value; 400 | if (parent.Text == "Key" || parent.Text == "Name") 401 | keyValue = value; 402 | if (parent.Text == "Text" || parent.Text == "Texture") 403 | altValue = value; 404 | } 405 | } 406 | 407 | if (items.Length == 1 && node != null && lastValue != null) 408 | { 409 | node.ImageIndex = node.SelectedImageIndex = 1; 410 | node.Nodes.Clear(); 411 | node.Tag = lastValue; 412 | return lastValue; 413 | } 414 | return keyValue ?? altValue; 415 | } 416 | 417 | infoTreeView.EndUpdate(); 418 | Cursor.Current = Cursors.Default; 419 | } 420 | 421 | #endregion 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /Src/Forms/ExplorerForm.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Drawing; 5 | using System.Drawing.Imaging; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Threading.Tasks; 9 | using System.Windows.Forms; 10 | 11 | namespace SOR4Explorer 12 | { 13 | public partial class ExplorerForm : Form 14 | { 15 | private readonly TextureLibrary library = new TextureLibrary(); 16 | private readonly DataLibrary data = new DataLibrary(); 17 | private readonly Timer timer; 18 | private readonly DataViewForm dataViewForm; 19 | private LocalizationData localization; 20 | 21 | #region Initialization and components 22 | 23 | private Panel appContainer; 24 | private SplitContainer splitContainer; 25 | private BufferedTreeView folderTreeView; 26 | private BufferedListView imageListView; 27 | private Panel dragInstructions; 28 | private Label instructionsLabel; 29 | private StatusStrip statusBar; 30 | private BlackToolStrip toolStrip; 31 | private ToolStripStatusLabel statusLabel; 32 | private ToolStripProgressBar progressBar; 33 | private ToolStripLabel gameStatusLabel; 34 | private ToolStripLabel changesLabel; 35 | private ToolStripButton applyButton; 36 | private ToolStripButton discardButton; 37 | 38 | public ExplorerForm() 39 | { 40 | dataViewForm = new DataViewForm(data); 41 | InitializeComponents(); 42 | 43 | library.OnTextureChangeDiscarded += Library_OnTextureChangeDiscarded; 44 | library.OnTextureChanged += Library_OnTextureChangesAdded; 45 | 46 | if (Settings.InstallationPath != null) 47 | LoadTextureLists(Settings.InstallationPath); 48 | 49 | timer = new Timer { Interval = 100 }; 50 | timer.Tick += Timer_Tick; 51 | timer.Start(); 52 | } 53 | 54 | void InitializeComponents() 55 | { 56 | SuspendLayout(); 57 | 58 | // 59 | // appContainer 60 | // 61 | appContainer = new Panel 62 | { 63 | Dock = DockStyle.Fill, 64 | BorderStyle = BorderStyle.None, 65 | Visible = false, 66 | }; 67 | appContainer.SuspendLayout(); 68 | 69 | // 70 | // statusBar 71 | // 72 | statusBar = new StatusStrip() 73 | { 74 | Dock = DockStyle.Bottom, 75 | Size = new Size(400, 32), 76 | SizingGrip = true, 77 | Stretch = true, 78 | AutoSize = false 79 | }; 80 | statusBar.Items.Add(statusLabel = new ToolStripStatusLabel() { 81 | Spring = true, 82 | Text = "Ready", 83 | TextAlign = ContentAlignment.MiddleLeft 84 | }); 85 | 86 | // 87 | // toolStrip 88 | // 89 | toolStrip = new BlackToolStrip(); 90 | var mainMenuButton = toolStrip.AddMenuItem(Program.BarsImage); 91 | mainMenuButton.DropDownOpening += (sender, args) => 92 | { 93 | mainMenuButton.DropDownItems.Clear(); 94 | mainMenuButton.DropDownItems.AddRange(MainMenu()); 95 | }; 96 | toolStrip.Items.Add(gameStatusLabel = new ToolStripLabel() 97 | { 98 | AutoSize = false, 99 | Height = toolStrip.Height, 100 | Width = 382 /* 314 */, 101 | TextAlign = ContentAlignment.MiddleLeft, 102 | BackColor = Color.White 103 | }); 104 | //toolStrip.AddMenuItem(Program.BarsImage); 105 | toolStrip.Items.Add(new ToolStripSeparator()); 106 | toolStrip.NextAlignment = ToolStripItemAlignment.Right; 107 | progressBar = toolStrip.AddProgressBar(); 108 | discardButton = toolStrip.AddButton(Program.TrashImage, "Discard", DiscardChanges); 109 | applyButton = toolStrip.AddButton(Program.SaveImage, "Apply", ApplyChanges); 110 | changesLabel = toolStrip.AddLabel(); 111 | progressBar.Visible = false; 112 | 113 | // 114 | // splitContainer 115 | // 116 | splitContainer = new SplitContainer 117 | { 118 | BackColor = SystemColors.Control, 119 | Dock = DockStyle.Fill, 120 | Location = new Point(0, 0), 121 | Name = "splitContainer", 122 | FixedPanel = FixedPanel.Panel1, 123 | Size = new Size(1422, 1006), 124 | SplitterDistance = 450, 125 | TabIndex = 0, 126 | }; 127 | splitContainer.BeginInit(); 128 | splitContainer.Panel1.SuspendLayout(); 129 | splitContainer.Panel2.SuspendLayout(); 130 | splitContainer.SuspendLayout(); 131 | splitContainer.Panel1.DockPadding.All = 9; 132 | 133 | // 134 | // folderTreeView 135 | // 136 | folderTreeView = new BufferedTreeView 137 | { 138 | BackColor = SystemColors.Control, 139 | BorderStyle = BorderStyle.None, 140 | Dock = DockStyle.Fill, 141 | Font = new Font("Tahoma", 9.0F, FontStyle.Regular, GraphicsUnit.Point, ((byte)(0))), 142 | Name = "folderTreeView", 143 | Size = new Size(474, 1006), 144 | TabIndex = 0, 145 | ItemHeight = 40, 146 | FullRowSelect = true, 147 | HideSelection = false, 148 | DrawMode = TreeViewDrawMode.OwnerDrawText, 149 | ImageList = new ImageList 150 | { 151 | ImageSize = new Size(32, 20), 152 | ColorDepth = ColorDepth.Depth32Bit 153 | } 154 | }; 155 | folderTreeView.ImageList.Images.Add(Program.FolderIconSmall); 156 | folderTreeView.AfterSelect += FolderTreeView_AfterSelect; 157 | folderTreeView.NodeMouseClick += FolderTreeView_MouseClick; 158 | folderTreeView.DrawNode += FolderTreeView_DrawNode; 159 | splitContainer.Panel1.Controls.Add(folderTreeView); 160 | 161 | // 162 | // imageListView 163 | // 164 | imageListView = new BufferedListView 165 | { 166 | BackColor = SystemColors.ControlDark, 167 | BorderStyle = BorderStyle.None, 168 | Dock = DockStyle.Fill, 169 | Location = new Point(0, 0), 170 | Name = "imageListView", 171 | Size = new Size(944, 1006), 172 | TabIndex = 0, 173 | View = View.LargeIcon, 174 | LargeImageList = new ImageList 175 | { 176 | ImageSize = new Size(200, 200), 177 | ColorDepth = ColorDepth.Depth32Bit 178 | } 179 | }; 180 | imageListView.SelectedIndexChanged += ImageListView_SelectedIndexChanged; 181 | imageListView.DoubleClick += ImageListView_DoubleClick; 182 | imageListView.MouseClick += ImageListView_MouseClick; 183 | imageListView.ItemDrag += ImageListView_ItemDrag; 184 | splitContainer.Panel2.Controls.Add(imageListView); 185 | 186 | // 187 | // DragInstructions 188 | // 189 | dragInstructions = new Panel() { Dock = DockStyle.Fill }; 190 | instructionsLabel = new Label() 191 | { 192 | Dock = DockStyle.Fill, 193 | Font = new Font("Tahoma", 20), 194 | TextAlign = ContentAlignment.MiddleCenter, 195 | }; 196 | instructionsLabel.Paint += InstructionsLabel_Paint; 197 | dragInstructions.Controls.Add(instructionsLabel); 198 | 199 | // 200 | // ExplorerForm 201 | // 202 | AutoScaleDimensions = new SizeF(12F, 25F); 203 | AutoScaleMode = AutoScaleMode.Font; 204 | ClientSize = new Size(1522, 1006); 205 | appContainer.Controls.Add(splitContainer); 206 | appContainer.Controls.Add(statusBar); 207 | appContainer.Controls.Add(toolStrip); 208 | Controls.Add(appContainer); 209 | Controls.Add(dragInstructions); 210 | Name = "ExplorerForm"; 211 | Text = "SOR4 Explorer"; 212 | DoubleBuffered = true; 213 | AllowDrop = true; 214 | Icon = Program.Icon; 215 | 216 | FormClosing += ExplorerForm_FormClosing; 217 | DragDrop += ExplorerForm_DragDrop; 218 | DragEnter += ExplorerForm_DragEnter; 219 | 220 | splitContainer.Panel1.ResumeLayout(false); 221 | splitContainer.Panel2.ResumeLayout(false); 222 | splitContainer.EndInit(); 223 | splitContainer.ResumeLayout(false); 224 | appContainer.ResumeLayout(false); 225 | ResumeLayout(false); 226 | } 227 | 228 | #endregion 229 | 230 | #region Events 231 | 232 | protected override bool ProcessCmdKey(ref Message msg, Keys keyData) 233 | { 234 | if (keyData == (Keys.Control | Keys.S)) 235 | ApplyChanges(); 236 | 237 | if (keyData == (Keys.Control | Keys.E) || keyData == (Keys.Control | Keys.A)) 238 | { 239 | foreach (ListViewItem item in imageListView.Items) 240 | item.Selected = true; 241 | imageListView.Focus(); 242 | } 243 | if (keyData == Keys.Escape) 244 | Close(); 245 | if (keyData == (Keys.Control | Keys.W)) 246 | { 247 | if (dragInstructions.Visible == false && MessageBox.Show( 248 | this, 249 | "You are going to close the current library.\nAre you sure?", 250 | "Close library", 251 | MessageBoxButtons.YesNo, 252 | MessageBoxIcon.Question 253 | ) == DialogResult.Yes) 254 | { 255 | library.Clear(); 256 | imageListView.Clear(); 257 | folderTreeView.Nodes.Clear(); 258 | appContainer.Visible = false; 259 | dragInstructions.Visible = true; 260 | Settings.InstallationPath = null; 261 | } 262 | } 263 | return base.ProcessCmdKey(ref msg, keyData); 264 | } 265 | 266 | private void InstructionsLabel_Paint(object sender, PaintEventArgs e) 267 | { 268 | var text = "Drag here your\nStreets of Rage 4\ninstallation folder"; 269 | var lines = text.Split('\n'); 270 | var font = instructionsLabel.Font; 271 | var boldFont = new Font(font, FontStyle.Bold); 272 | Graphics g = e.Graphics; 273 | Brush brush = new SolidBrush(Color.Black); 274 | float lineSpacing = 1.25f; 275 | 276 | SizeF size = g.MeasureString(text, font); 277 | var lineHeight = g.MeasureString(lines[0], font).Height; 278 | size = new SizeF(size.Width, size.Height + lineHeight * (lineSpacing - 1) * (lines.Length - 1)); 279 | 280 | var greyBrush = new SolidBrush(Color.FromArgb(128, 128, 128)); 281 | var greyPen = new Pen(greyBrush, 8) { DashPattern = new float[] { 4.0f, 2.0f } }; 282 | var innerRect = new Rectangle( 283 | new Point((int)(instructionsLabel.Width / 2 - size.Width / 2), (int)(instructionsLabel.Height / 2 - size.Height / 2)), 284 | new Size((int)size.Width, (int)size.Height)); 285 | innerRect.Inflate(80, 40); 286 | innerRect.Offset(0, 10); 287 | g.DrawRoundedRectangle(greyPen, innerRect, 30); 288 | 289 | float y = instructionsLabel.Height / 2 - size.Height / 2; 290 | for (int i = 0; i < lines.Length; ++i) 291 | { 292 | var lineFont = i == 1 ? boldFont : font; 293 | SizeF lineSize = g.MeasureString(lines[i], lineFont); 294 | g.DrawString(lines[i], lineFont, brush, new PointF(instructionsLabel.Width / 2 - lineSize.Width / 2, y)); 295 | y += lineSize.Height * lineSpacing; 296 | } 297 | } 298 | 299 | private void ExplorerForm_FormClosing(object sender, FormClosingEventArgs e) 300 | { 301 | if (library.ImageChanges.Count > 0) 302 | { 303 | if (MessageBox.Show("Unsaved changes will be lost.\nAre you sure?", "Close", 304 | MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) 305 | e.Cancel = true; 306 | } 307 | } 308 | 309 | private void ExplorerForm_DragEnter(object sender, DragEventArgs e) 310 | { 311 | if (e.Data.GetDataPresent("SOR4Explorer")) 312 | return; 313 | if (e.Data.GetDataPresent(DataFormats.FileDrop)) 314 | { 315 | var paths = ((string[])e.Data.GetData(DataFormats.FileDrop)); 316 | if (paths.Length == 1 && Directory.Exists(paths[0])) 317 | e.Effect = DragDropEffects.Link; 318 | else 319 | e.Effect = DragDropEffects.Move; 320 | } 321 | } 322 | 323 | private void ExplorerForm_DragDrop(object sender, DragEventArgs e) 324 | { 325 | if (e.Data.GetDataPresent(DataFormats.FileDrop)) 326 | { 327 | var paths = ((string[])e.Data.GetData(DataFormats.FileDrop)); 328 | if (paths.Length == 1 && Directory.Exists(paths[0]) && IsInstallationFolder(paths[0])) 329 | { 330 | imageListView.Clear(); 331 | folderTreeView.Nodes.Clear(); 332 | LoadTextureLists(paths[0]); 333 | return; 334 | } 335 | if (instructionsLabel.Visible) 336 | { 337 | BringToFront(); 338 | MessageBox.Show( 339 | "Please select a valid Streets of Rage 4 installation folder", 340 | "Data folder not found", 341 | MessageBoxButtons.OK, 342 | MessageBoxIcon.Error, 343 | MessageBoxDefaultButton.Button1, 344 | MessageBoxOptions.DefaultDesktopOnly); 345 | return; 346 | } 347 | 348 | var root = folderTreeView.SelectedNode.Name; 349 | foreach (var path in paths) 350 | { 351 | if (File.Exists(path)) 352 | { 353 | string name = Path.GetFileNameWithoutExtension(path); 354 | string filename = Path.Combine(root, name); 355 | if (library.Contains(filename) == false) 356 | { 357 | MessageBox.Show( 358 | this, 359 | $"{filename}\ndoes not exist in the textures library", 360 | "Invalid texture replacement", 361 | MessageBoxButtons.OK, 362 | MessageBoxIcon.Error, 363 | MessageBoxDefaultButton.Button1); 364 | break; 365 | } 366 | 367 | Bitmap image; 368 | try 369 | { 370 | image = new Bitmap(path); 371 | } 372 | catch (Exception exception) 373 | { 374 | MessageBox.Show( 375 | this, 376 | $"Unable to open image {path}\n{exception.Message}", 377 | "Invalid image", 378 | MessageBoxButtons.OK, 379 | MessageBoxIcon.Error, 380 | MessageBoxDefaultButton.Button1); 381 | return; 382 | } 383 | library.AddChange(filename, image); 384 | UpdateChangesLabel(); 385 | 386 | foreach (ListViewItem item in imageListView.Items) 387 | { 388 | if (item.Tag is TextureInfo info && info.name == filename) 389 | { 390 | imageListView.LargeImageList.Images.Add(TextureLoader.ScaledImage(image)); 391 | item.ImageIndex = imageListView.LargeImageList.Images.Count - 1; 392 | item.Font = new Font(item.Font, FontStyle.Bold); 393 | } 394 | } 395 | } 396 | } 397 | } 398 | } 399 | 400 | private void ImageListView_ItemDrag(object sender, ItemDragEventArgs ev) 401 | { 402 | if (ev.Button == MouseButtons.Left) 403 | { 404 | var images = imageListView.SelectedItems.Cast().Where(n => n.Tag is TextureInfo).Select(n => ((TextureInfo)n.Tag)).ToArray(); 405 | if (images.Length > 0) 406 | imageListView.DoDragDrop(new DraggableImages(library, images), DragDropEffects.Copy); 407 | } 408 | } 409 | 410 | private void ImageListView_DoubleClick(object sender, EventArgs e) 411 | { 412 | if (imageListView.SelectedItems.Count == 1) 413 | { 414 | var item = imageListView.SelectedItems[0]; 415 | if (item.Tag is string path) 416 | { 417 | var node = folderTreeView.SelectedNode; 418 | var child = node.Nodes.Cast().FirstOrDefault(n => n.Name == path); 419 | if (child != null) 420 | folderTreeView.SelectedNode = child; 421 | } 422 | else if (item.Tag is TextureInfo info) 423 | { 424 | var image = library.LoadTexture(info); 425 | new ImagePreviewForm(image, info.name).Show(); 426 | } 427 | } 428 | } 429 | 430 | private void ImageListView_MouseClick(object sender, MouseEventArgs e) 431 | { 432 | if (e.Button == MouseButtons.Right) 433 | { 434 | if (imageListView.SelectedItems.Count == 1) 435 | { 436 | switch (imageListView.SelectedItems[0].Tag) 437 | { 438 | case TextureInfo info: 439 | ContextMenu.FromImage(library, info).Show(imageListView, e.Location); 440 | break; 441 | case string path: 442 | var images = library.GetTextures(path); 443 | ContextMenu.FromImages(library, images, true, SaveProgress()).Show(imageListView, e.Location); 444 | break; 445 | } 446 | } 447 | else if (imageListView.SelectedItems.Count > 1) 448 | { 449 | var images = imageListView.SelectedItems.Cast().Select(n => (TextureInfo)n.Tag).ToList(); 450 | var contextMenu = ContextMenu.FromImages(library, images, progress: SaveProgress()); 451 | contextMenu.Show(imageListView, e.Location); 452 | } 453 | } 454 | } 455 | 456 | private void FolderTreeView_MouseClick(object sender, TreeNodeMouseClickEventArgs e) 457 | { 458 | if (e.Button == MouseButtons.Right && e.Node != null) 459 | { 460 | folderTreeView.SelectedNode = e.Node; 461 | var folder = e.Node.Name; 462 | var images = library.GetTextures(folder); 463 | progressBar.Maximum = images.Count; 464 | progressBar.Value = 0; 465 | ContextMenu.FromImages(library, images, true, SaveProgress()).Show(folderTreeView, e.Location); 466 | } 467 | } 468 | 469 | private void FolderTreeView_DrawNode(object sender, DrawTreeNodeEventArgs e) 470 | { 471 | var node = e.Node; 472 | var treeView = e.Node.TreeView; 473 | Font treeFont = node.NodeFont ?? treeView.Font; 474 | var color = treeView.SelectedNode == node ? SystemColors.HighlightText : treeView.ForeColor; 475 | if (treeView.SelectedNode == node) 476 | e.Graphics.FillRectangle(treeView.Focused ? SystemBrushes.Highlight: Brushes.Gray, e.Bounds); 477 | TextRenderer.DrawText(e.Graphics, node.Text, treeFont, e.Bounds, color, 478 | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.VerticalCenter); 479 | } 480 | 481 | private void FolderTreeView_AfterSelect(object sender, TreeViewEventArgs e) 482 | { 483 | var path = folderTreeView.SelectedNode.Name; 484 | FillImageList(path); 485 | statusLabel.Text = $"{(path == "" ? "Root":path)} folder ({library.CountTextures(path)} images in tree)"; 486 | } 487 | 488 | private void ImageListView_SelectedIndexChanged(object sender, EventArgs e) 489 | { 490 | int count = imageListView.SelectedItems.Count; 491 | if (count > 0) 492 | statusLabel.Text = $"{count} images selected"; 493 | else 494 | statusLabel.Text = " "; 495 | } 496 | 497 | #endregion 498 | 499 | #region Filling the folder tree 500 | 501 | public bool IsInstallationFolder(string installationPath) 502 | { 503 | string dataFolder = Path.Combine(installationPath, "data"); 504 | string texturesFile = Path.Combine(dataFolder, "textures"); 505 | string tableFile = Path.Combine(dataFolder, "texture_table"); 506 | return Directory.Exists(dataFolder) && File.Exists(texturesFile) && File.Exists(tableFile); 507 | } 508 | 509 | public void LoadTextureLists(string installationPath) 510 | { 511 | if (library.Load(installationPath) == false) 512 | { 513 | UpdateChangesLabel(); 514 | MessageBox.Show( 515 | "Please select a valid Streets of Rage 4 installation folder", 516 | "Data folder invalid", 517 | MessageBoxButtons.OK, 518 | MessageBoxIcon.Error, 519 | MessageBoxDefaultButton.Button1, 520 | MessageBoxOptions.DefaultDesktopOnly); 521 | dragInstructions.Visible = true; 522 | appContainer.Visible = false; 523 | return; 524 | } 525 | 526 | data.Load(installationPath); 527 | localization = data.Unserialize("localization"); 528 | 529 | var folderNodes = new Dictionary { 530 | [""] = folderTreeView.Nodes.Add("", "/") 531 | }; 532 | foreach (var list in library.Lists.Values) 533 | { 534 | foreach (var item in list) 535 | { 536 | var path = Path.GetDirectoryName(item.name); 537 | if (!folderNodes.ContainsKey(path) && path != "") 538 | AddPathToTree(path); 539 | } 540 | } 541 | if (folderNodes.Count > 1) 542 | { 543 | folderTreeView.Nodes[0].Expand(); 544 | foreach (TreeNode node in folderTreeView.Nodes[0].Nodes) 545 | node.Expand(); 546 | dragInstructions.Visible = false; 547 | appContainer.Visible = true; 548 | Settings.InstallationPath = installationPath; 549 | } 550 | else 551 | { 552 | dragInstructions.Visible = true; 553 | appContainer.Visible = false; 554 | } 555 | 556 | Activate(); 557 | UpdateChangesLabel(); 558 | 559 | TreeNode AddPathToTree(string folder) 560 | { 561 | var path = Path.GetDirectoryName(folder); 562 | var name = Path.GetFileName(folder); 563 | var node = folderNodes.TryGetValue(path, out TreeNode parent) ? 564 | parent.Nodes.Add(folder, name) : 565 | AddPathToTree(path).Nodes.Add(folder, name); 566 | node.ImageIndex = 0; 567 | folderNodes[folder] = node; 568 | return node; 569 | } 570 | } 571 | 572 | #endregion 573 | 574 | #region Filling the folder view 575 | 576 | private void FillImageList(string path) 577 | { 578 | imageListView.BeginUpdate(); 579 | imageListView.LargeImageList.Images.Clear(); 580 | imageListView.LargeImageList.Images.Add(Program.FolderIcon); 581 | imageListView.Clear(); 582 | loadOps.Clear(); 583 | 584 | foreach (var subfolder in library.GetSubfolders(path)) 585 | { 586 | var subpath = Path.Combine(path, subfolder); 587 | var listItem = imageListView.Items.Add($"{subfolder}\n({library.CountTextures(subpath)} textures)"); 588 | listItem.Tag = subpath; 589 | listItem.ImageIndex = 0; 590 | } 591 | 592 | List images = new List(); 593 | foreach (var list in library.Lists.Values) 594 | images.AddRange(list.Where(n => n.changed == false && Path.GetDirectoryName(n.name) == path)); 595 | images.Sort((a, b) => a.name.CompareTo(b.name)); 596 | 597 | foreach (var info in images) 598 | { 599 | if (Path.GetDirectoryName(info.name) == path) 600 | { 601 | var filename = Path.GetFileName(info.name); 602 | var listItem = imageListView.Items.Add(filename); 603 | listItem.Tag = info; 604 | if (info.datafile == null) 605 | listItem.Font = new Font(listItem.Font, FontStyle.Bold); 606 | loadOps.Enqueue(new ImageToLoad { listItem = listItem, info = info }); 607 | } 608 | } 609 | imageListView.EndUpdate(); 610 | } 611 | 612 | #endregion 613 | 614 | #region Image loading 615 | 616 | private struct ImageToLoad 617 | { 618 | public ListViewItem listItem; 619 | public TextureInfo info; 620 | } 621 | private readonly Queue loadOps = new Queue(); 622 | 623 | struct Result 624 | { 625 | public Bitmap image; 626 | public ListViewItem listItem; 627 | } 628 | private readonly List> currentTask = new List>(); 629 | 630 | private void AddBackgroundLoadTask(ImageToLoad op) 631 | { 632 | var data = library.LoadTextureData(op.info); 633 | var task = Task.Run(() => { 634 | var image = data == null ? library.LoadTexture(op.info) : TextureLoader.Load(op.info, data); 635 | Console.WriteLine($"Image {op.info.name} loaded"); 636 | return new Result { image = image, listItem = op.listItem }; 637 | }); 638 | currentTask.Add(task); 639 | } 640 | 641 | private void Library_OnTextureChangesAdded(TextureInfo obj, Bitmap image) 642 | { 643 | imageListView.LargeImageList.Images.Add(TextureLoader.ScaledImage(image)); 644 | 645 | foreach (ListViewItem item in imageListView.Items) 646 | { 647 | if (item.Tag is TextureInfo info && obj.name == info.name) 648 | { 649 | item.Font = new Font(item.Font, FontStyle.Bold); 650 | item.ImageIndex = imageListView.LargeImageList.Images.Count - 1; 651 | } 652 | } 653 | UpdateChangesLabel(); 654 | } 655 | 656 | private void Library_OnTextureChangeDiscarded(TextureInfo obj) 657 | { 658 | foreach (ListViewItem item in imageListView.Items) 659 | { 660 | if (item.Tag is TextureInfo info && obj.name == info.name) 661 | { 662 | item.Font = new Font(item.Font, FontStyle.Regular); 663 | loadOps.Enqueue(new ImageToLoad() { listItem = item, info = obj }); 664 | } 665 | } 666 | UpdateChangesLabel(); 667 | } 668 | 669 | private void Timer_Tick(object sender, EventArgs e) 670 | { 671 | int emptySlots = Environment.ProcessorCount; 672 | 673 | if (currentTask.Count > 0) 674 | { 675 | for (int n = 0; n < currentTask.Count; n++) 676 | { 677 | var task = currentTask[n]; 678 | if (task.IsCompleted) 679 | { 680 | var result = task.Result; 681 | var image = result.image; 682 | var listItem = result.listItem; 683 | if (image != null && listItem != null && listItem.ImageList != null) 684 | { 685 | var suffix = $"\n({image.Width}x{image.Height})"; 686 | if (listItem.Text.EndsWith(suffix) == false) 687 | listItem.Text += suffix; 688 | listItem.ImageList.Images.Add(TextureLoader.ScaledImage(image)); 689 | listItem.ImageIndex = listItem.ImageList.Images.Count - 1; 690 | } 691 | currentTask.RemoveAt(n--); 692 | } 693 | else 694 | { 695 | emptySlots--; 696 | } 697 | } 698 | } 699 | 700 | for (int n = 0; n < emptySlots && loadOps.Count > 0; n++) 701 | AddBackgroundLoadTask(loadOps.Dequeue()); 702 | } 703 | 704 | #endregion 705 | 706 | #region Control logic 707 | 708 | void RestoreGameFiles() 709 | { 710 | if (MessageBox.Show( 711 | this, 712 | "You are going to restore the game's files to their original state,\nlosing any texture modifications currently active.\n\nAre you sure?", 713 | "Close library", 714 | MessageBoxButtons.YesNo, 715 | MessageBoxIcon.Question 716 | ) == DialogResult.Yes) 717 | { 718 | library.RestoreFromBackups(); 719 | UpdateChangesLabel(); 720 | } 721 | } 722 | 723 | void ApplyChanges() 724 | { 725 | if (library.ImageChanges.Count > 0) 726 | { 727 | changesLabel.Text = "Saving changes..."; 728 | Cursor.Current = Cursors.WaitCursor; 729 | Refresh(); 730 | library.SaveChanges(); 731 | Cursor.Current = Cursors.Default; 732 | } 733 | UpdateChangesLabel(); 734 | } 735 | 736 | void DiscardChanges() 737 | { 738 | if (library.ImageChanges.Count > 0) 739 | { 740 | if (dragInstructions.Visible == false && MessageBox.Show( 741 | this, 742 | $"You are going to discard {library.ImageChanges.Count:N0} changes.\nAre you sure?", 743 | "Close library", 744 | MessageBoxButtons.YesNo, 745 | MessageBoxIcon.Question 746 | ) == DialogResult.Yes) 747 | { 748 | library.DiscardChanges(); 749 | UpdateChangesLabel(); 750 | } 751 | } 752 | } 753 | 754 | void UpdateChangesLabel() 755 | { 756 | if (progressBar.Visible) 757 | { 758 | applyButton.Visible = false; 759 | discardButton.Visible = false; 760 | return; 761 | } 762 | 763 | if (library.NonOriginalCount == 0) 764 | gameStatusLabel.Text = "Original textures"; 765 | else 766 | gameStatusLabel.Text = $"{library.NonOriginalCount:N0} textures modified"; 767 | 768 | var count = library.ImageChanges.Count; 769 | if (count == 0) 770 | { 771 | applyButton.Visible = false; 772 | discardButton.Visible = false; 773 | changesLabel.Text = "Drag replacements over existing textures \u2935"; 774 | } 775 | else 776 | { 777 | changesLabel.Text = $"{count:N0} texture{(count > 1 ? "s":"")} changed"; 778 | applyButton.Visible = true; 779 | discardButton.Visible = true; 780 | } 781 | } 782 | 783 | public ToolStripItem[] MainMenu() 784 | { 785 | return new ToolStripItem[] { 786 | new ToolStripMenuItem("&Restore game data files", null, (sender, ev) => RestoreGameFiles()) { Enabled = library.GameFilesChanged() }, 787 | new ToolStripSeparator(), 788 | new ToolStripMenuItem("Update game files", null, (sender, ev) => ApplyChanges()) { Enabled = applyButton.Visible }, 789 | new ToolStripMenuItem("Discard changed textures", null, (sender, ev) => DiscardChanges()) { Enabled = applyButton.Visible }, 790 | new ToolStripSeparator(), 791 | new ToolStripMenuItem("View game data", null, (sender, ev) => { 792 | dataViewForm.FillObjects(); 793 | dataViewForm.Show(); 794 | }) 795 | }; 796 | } 797 | 798 | private Progress SaveProgress() 799 | { 800 | return new Progress(t => 801 | { 802 | if (statusBar == null || statusBar.Items == null || statusBar.Items.Count < 1) 803 | return; 804 | 805 | progressBar.Maximum = t.count; 806 | progressBar.Value = t.processed; 807 | 808 | if (t.processed == t.count) 809 | { 810 | progressBar.Visible = false; 811 | FillImageList(folderTreeView.SelectedNode.Name); 812 | UpdateChangesLabel(); 813 | } 814 | else 815 | { 816 | changesLabel.Text = $"Saving image {t.processed}/{t.count}"; 817 | progressBar.Visible = true; 818 | } 819 | }); 820 | } 821 | #endregion 822 | } 823 | } --------------------------------------------------------------------------------