Skip to content

Commit 99fe3be

Browse files
committed
Add project files.
1 parent efc55ed commit 99fe3be

File tree

6 files changed

+346
-0
lines changed

6 files changed

+346
-0
lines changed

Extractor.cs

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Formats.Tar;
3+
using System.IO.Compression;
4+
5+
/// <summary>
6+
/// Extracts files from a .unitypackage file.
7+
/// </summary>
8+
public class Extractor
9+
{
10+
/// <summary>
11+
/// The path to the .unitypackage file.
12+
/// </summary>
13+
public required string InputPath { get; init; }
14+
15+
/// <summary>
16+
/// The directory to output to.
17+
/// </summary>
18+
public required string OutputDir { get; init; }
19+
20+
/// <summary>
21+
/// Extracts a <i>.unitypackage</i> archive.
22+
/// </summary>
23+
public void Extract()
24+
{
25+
if (!InputPath.EndsWith(".unitypackage", StringComparison.OrdinalIgnoreCase))
26+
throw new InvalidOperationException("The input file must end with .unitypackage");
27+
28+
// Add the input file name to the output path.
29+
var outputDir = Path.Combine(Path.GetFullPath(OutputDir), Path.GetFileNameWithoutExtension(InputPath));
30+
if (!Directory.Exists(outputDir))
31+
Directory.CreateDirectory(outputDir);
32+
outputDir += Path.DirectorySeparatorChar; // This is so SafeCombine can check the root correctly.
33+
34+
Console.Write($"Extracting '{InputPath}' to '{outputDir}'... ");
35+
36+
using var fileStream = File.OpenRead(InputPath);
37+
using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress);
38+
using var tarReader = new TarReader(gzipStream);
39+
using (var progress = new ProgressBar())
40+
{
41+
while (true)
42+
{
43+
// Read from the TAR file.
44+
var entry = tarReader.GetNextEntry();
45+
if (entry == null)
46+
break;
47+
48+
// Extract it.
49+
if (entry.EntryType == TarEntryType.RegularFile)
50+
ProcessTarEntry(entry, outputDir);
51+
52+
// Report progress.
53+
progress.Report((double)fileStream.Position / fileStream.Length);
54+
}
55+
}
56+
57+
Console.WriteLine("done.");
58+
}
59+
60+
private readonly Dictionary<string, string?> _guidToAssetPath = [];
61+
62+
/// <summary>
63+
/// Processes a single file in the .unitypackage file.
64+
/// </summary>
65+
/// <param name="entry"> The TAR entry to process. </param>
66+
/// <param name="outputDir"> The output directory. </param>
67+
private void ProcessTarEntry(TarEntry entry, string outputDir)
68+
{
69+
string fileName = Path.GetFileName(entry.Name);
70+
string guid = VerifyNonNull(Path.GetDirectoryName(entry.Name));
71+
if (fileName == "asset")
72+
{
73+
string outputFilePath;
74+
if (_guidToAssetPath.TryGetValue(guid, out var assetPath))
75+
{
76+
// If we previously encountered a 'pathname' use that.
77+
VerifyNonNull(assetPath);
78+
outputFilePath = SafeCombine(outputDir, assetPath);
79+
CreatePathDirectoriesIfNecessary(outputFilePath);
80+
}
81+
else
82+
{
83+
// Extract the file and call it '<guid>'.
84+
_guidToAssetPath[guid] = null;
85+
outputFilePath = SafeCombine(outputDir, guid);
86+
}
87+
88+
// Extract the file.
89+
entry.ExtractToFile(outputFilePath, overwrite: false);
90+
}
91+
else if (fileName == "pathname")
92+
{
93+
VerifyNonNull(entry.DataStream);
94+
95+
// [ascii path] e.g. Assets/Footstep Sounds/Water and Mud/Water Running 1_10.wav
96+
// ASCII line feed (0xA)
97+
// 00
98+
string assetPath = VerifyNonNull(new StreamReader(entry.DataStream, System.Text.Encoding.ASCII).ReadLine());
99+
if (_guidToAssetPath.TryGetValue(guid, out var existingAssetPath))
100+
{
101+
if (existingAssetPath != null)
102+
throw new FormatException("The format of the file is invalid; is this a valid Unity package file?");
103+
assetPath = SafeCombine(outputDir, assetPath);
104+
CreatePathDirectoriesIfNecessary(assetPath);
105+
File.Move(SafeCombine(outputDir, guid), assetPath);
106+
}
107+
_guidToAssetPath[guid] = assetPath;
108+
}
109+
}
110+
111+
/// <summary>
112+
/// Throws an exception if the value is null.
113+
/// </summary>
114+
/// <typeparam name="T"></typeparam>
115+
/// <param name="value"> The value to check for null. </param>
116+
/// <returns></returns>
117+
private static T VerifyNonNull<T>([NotNull] T? value)
118+
{
119+
if (value == null)
120+
throw new FormatException("The format of the file is invalid; is this a valid Unity package file?");
121+
return value;
122+
}
123+
124+
/// <summary>
125+
/// Acts like Path.Combine but checks the resulting path starts with <paramref name="rootDir"/>.
126+
/// </summary>
127+
/// <param name="rootDir"> The root directory. </param>
128+
/// <param name="relativePath"> The relative path to append. </param>
129+
/// <returns> The combined file path. </returns>
130+
private static string SafeCombine(string rootDir, string relativePath)
131+
{
132+
var result = Path.Combine(rootDir, relativePath);
133+
if (!result.StartsWith(rootDir, StringComparison.Ordinal))
134+
throw new InvalidOperationException($"Invalid path '{result}'; it should start with '{rootDir}'.");
135+
return result;
136+
}
137+
138+
private readonly HashSet<string> _createdDirectories = [];
139+
140+
/// <summary>
141+
/// Creates any directories in the given path, if they don't already exist.
142+
/// </summary>
143+
/// <param name="path"> The file path. </param>
144+
private void CreatePathDirectoriesIfNecessary(string path)
145+
{
146+
// Get the directory from the path.
147+
var dir = Path.GetDirectoryName(path);
148+
if (string.IsNullOrEmpty(dir))
149+
return;
150+
151+
// Fast cache check.
152+
if (_createdDirectories.Contains(dir))
153+
return;
154+
155+
// Create it if it doesn't exist.
156+
if (!Directory.Exists(dir))
157+
Directory.CreateDirectory(dir);
158+
159+
// Add to cache.
160+
_createdDirectories.Add(dir);
161+
}
162+
}

Program.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Invocation;
3+
4+
Console.OutputEncoding = System.Text.Encoding.UTF8;
5+
var inputPathArg = new Argument<string>("inputPath", "The path to the .unitypackage file to extract.");
6+
var outputDirArg = new Argument<string>("outputDir", () => ".", "The directory to extract to. A new directory with the same name as the input file (excluding the extension) will be created inside the specified directory.");
7+
var rootCommand = new RootCommand(description: "Extracts the contents of a .unitypackage file into a new directory.")
8+
{
9+
inputPathArg, outputDirArg
10+
};
11+
rootCommand.SetHandler((InvocationContext context) =>
12+
{
13+
var extractor = new Extractor
14+
{
15+
InputPath = context.ParseResult.GetValueForArgument(inputPathArg),
16+
OutputDir = context.ParseResult.GetValueForArgument(outputDirArg)
17+
};
18+
try
19+
{
20+
extractor.Extract();
21+
}
22+
catch (Exception ex)
23+
{
24+
Console.ForegroundColor = ConsoleColor.Red;
25+
Console.Error.WriteLine();
26+
Console.Error.WriteLine($"ERROR: {ex.Message}");
27+
Console.ResetColor();
28+
context.ExitCode = 1;
29+
}
30+
});
31+
return rootCommand.Invoke(args);

ProgressBar.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.Text;
2+
3+
// https://gist.github.com/DanielSWolf/0ab6a96899cc5377bf54
4+
// Code is under the MIT License: http://opensource.org/licenses/MIT
5+
6+
/// <summary>
7+
/// An ASCII progress bar
8+
/// </summary>
9+
public class ProgressBar : IDisposable, IProgress<double>
10+
{
11+
private const int blockCount = 10;
12+
private readonly TimeSpan animationInterval = TimeSpan.FromSeconds(1.0 / 8);
13+
private const string animation = @"|/-\";
14+
15+
private readonly Timer timer;
16+
17+
private double currentProgress = 0;
18+
private string currentText = string.Empty;
19+
private bool disposed = false;
20+
private int animationIndex = 0;
21+
22+
public ProgressBar()
23+
{
24+
timer = new Timer(TimerHandler);
25+
26+
// A progress bar is only for temporary display in a console window.
27+
// If the console output is redirected to a file, draw nothing.
28+
// Otherwise, we'll end up with a lot of garbage in the target file.
29+
if (!Console.IsOutputRedirected)
30+
{
31+
ResetTimer();
32+
}
33+
}
34+
35+
public void Report(double value)
36+
{
37+
// Make sure value is in [0..1] range
38+
value = Math.Max(0, Math.Min(1, value));
39+
Interlocked.Exchange(ref currentProgress, value);
40+
}
41+
42+
private void TimerHandler(object state)
43+
{
44+
lock (timer)
45+
{
46+
if (disposed) return;
47+
48+
int progressBlockCount = (int)(currentProgress * blockCount);
49+
int percent = (int)(currentProgress * 100);
50+
string text = string.Format("[{0}{1}] {2,3}% {3}",
51+
new string('#', progressBlockCount), new string('-', blockCount - progressBlockCount),
52+
percent,
53+
animation[animationIndex++ % animation.Length]);
54+
UpdateText(text);
55+
56+
ResetTimer();
57+
}
58+
}
59+
60+
private void UpdateText(string text)
61+
{
62+
// Get length of common portion
63+
int commonPrefixLength = 0;
64+
int commonLength = Math.Min(currentText.Length, text.Length);
65+
while (commonPrefixLength < commonLength && text[commonPrefixLength] == currentText[commonPrefixLength])
66+
{
67+
commonPrefixLength++;
68+
}
69+
70+
// Backtrack to the first differing character
71+
StringBuilder outputBuilder = new StringBuilder();
72+
outputBuilder.Append('\b', currentText.Length - commonPrefixLength);
73+
74+
// Output new suffix
75+
outputBuilder.Append(text.Substring(commonPrefixLength));
76+
77+
// If the new text is shorter than the old one: delete overlapping characters
78+
int overlapCount = currentText.Length - text.Length;
79+
if (overlapCount > 0)
80+
{
81+
outputBuilder.Append(' ', overlapCount);
82+
outputBuilder.Append('\b', overlapCount);
83+
}
84+
85+
Console.Write(outputBuilder);
86+
currentText = text;
87+
}
88+
89+
private void ResetTimer()
90+
{
91+
timer.Change(animationInterval, TimeSpan.FromMilliseconds(-1));
92+
}
93+
94+
public void Dispose()
95+
{
96+
lock (timer)
97+
{
98+
disposed = true;
99+
UpdateText(string.Empty);
100+
}
101+
}
102+
103+
}

Properties/launchSettings.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"profiles": {
3+
"UnityPackageExtractor": {
4+
"commandName": "Project"
5+
}
6+
}
7+
}

UnityPackageExtractor.csproj

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<PublishAot>true</PublishAot>
9+
<OptimizationPreference>Size</OptimizationPreference>
10+
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
11+
<InvariantGlobalization>true</InvariantGlobalization>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
16+
</ItemGroup>
17+
18+
</Project>

UnityPackageExtractor.sln

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
2+
Microsoft Visual Studio Solution File, Format Version 12.00
3+
# Visual Studio Version 17
4+
VisualStudioVersion = 17.9.34607.119
5+
MinimumVisualStudioVersion = 10.0.40219.1
6+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnityPackageExtractor", "UnityPackageExtractor.csproj", "{B3D85A24-35F4-48F8-B331-00210581ED64}"
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+
{B3D85A24-35F4-48F8-B331-00210581ED64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
15+
{B3D85A24-35F4-48F8-B331-00210581ED64}.Debug|Any CPU.Build.0 = Debug|Any CPU
16+
{B3D85A24-35F4-48F8-B331-00210581ED64}.Release|Any CPU.ActiveCfg = Release|Any CPU
17+
{B3D85A24-35F4-48F8-B331-00210581ED64}.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 = {564AF879-3E49-4B1E-8F79-B9053EC34BFC}
24+
EndGlobalSection
25+
EndGlobal

0 commit comments

Comments
 (0)