├── 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 | }
--------------------------------------------------------------------------------