Skip to content

Commit 784975c

Browse files
NainetenNaineten
authored andcommitted
feature: decode Unreal Engine OFPA filenames into human-readable actor names
Unreal Engine's One File Per Actor (OFPA) system stores actors as files with opaque hash-based names (e.g. `KCBX0GWLTFQT9RJ8M1LY8.uasset`), making it impossible to identify modified actors without opening the editor. This adds opt-in OFPA decoding that resolves these hashes to actor names by reading the binary `.uasset` header. The feature is controlled by a per-repository toggle in Repository Configuration. Core: - OFPAParser: binary reader for UE4/UE5 `.uasset` NameMap headers - OFPAFilePrefixReader: reads only the first 256KB to avoid OOM on large assets - OFPAGitBatchReader: uses `git cat-file --batch` for efficient bulk reads - OFPANameLookup: async lookup with working-tree-first, index/HEAD fallback - OFPADecodingContext: shared refresh/stale-guard logic used by all ViewModels Integration: - WorkingCopy, CommitDetail, StashesPage: async OFPA refresh via OFPADecodingContext - ChangeCollectionView: display decoded names via OFPAConverters.PathToDisplayName - RepositoryConfigure: `EnableOFPADecoding` toggle with explanatory tooltip Tests: - OFPAParserTests: decoding across UE 5.3, 5.6, 5.7 formats - OFPAFilePrefixReaderTests: prefix reading and boundary conditions - OFPANameLookupTests: working tree, index fallback, and revision object lookup - OFPAConvertersTests: display name converter edge cases
1 parent 2f06b9e commit 784975c

34 files changed

+1614
-13
lines changed

SourceGit.slnx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
<Project Path="src/SourceGit.csproj" />
6161
</Folder>
6262

63+
<Folder Name="/tests/">
64+
<Project Path="tests/SourceGit.Tests/SourceGit.Tests.csproj" />
65+
</Folder>
66+
6367
<Folder Name="/files/">
6468
<File Path=".editorconfig"/>
6569
<File Path=".gitattributes"/>

src/Converters/OFPAConverters.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.IO;
5+
using Avalonia.Data.Converters;
6+
7+
namespace SourceGit.Converters
8+
{
9+
public class PathToDisplayNameConverter : IMultiValueConverter
10+
{
11+
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
12+
{
13+
if (values.Count < 2)
14+
return "";
15+
16+
string path = values[0] as string ?? string.Empty;
17+
var decodedPaths = values[1] as IReadOnlyDictionary<string, string>;
18+
19+
if (decodedPaths != null &&
20+
decodedPaths.TryGetValue(path, out var decoded) &&
21+
!string.IsNullOrEmpty(decoded))
22+
{
23+
return decoded;
24+
}
25+
26+
if (parameter as string == "PureFileName")
27+
return Path.GetFileName(path);
28+
29+
return path;
30+
}
31+
}
32+
33+
public static class OFPAConverters
34+
{
35+
public static readonly PathToDisplayNameConverter PathToDisplayName = new();
36+
}
37+
}

src/Models/RepositorySettings.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ public string PreferredOpenAIService
5454
set;
5555
} = "---";
5656

57+
public bool EnableOFPADecoding
58+
{
59+
get;
60+
set;
61+
} = false;
62+
5763
public AvaloniaList<CommitTemplate> CommitTemplates
5864
{
5965
get;

src/Resources/Icons.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,5 @@
158158
<StreamGeometry x:Key="Icons.Worktree">M853 267H514c-4 0-6-2-9-4l-38-66c-13-21-38-36-64-36H171c-41 0-75 34-75 75v555c0 41 34 75 75 75h683c41 0 75-34 75-75V341c0-41-34-75-75-75zm-683-43h233c4 0 6 2 9 4l38 66c13 21 38 36 64 36H853c6 0 11 4 11 11v75h-704V235c0-6 4-11 11-11zm683 576H171c-6 0-11-4-11-11V480h704V789c0 6-4 11-11 11z</StreamGeometry>
159159
<StreamGeometry x:Key="Icons.Worktree.Add">M896 96 614 96c-58 0-128-19-179-51C422 38 390 19 358 19L262 19 128 19c-70 0-128 58-128 128l0 736c0 70 58 128 128 128l768 0c70 0 128-58 128-128L1024 224C1024 154 966 96 896 96zM704 685 544 685l0 160c0 19-13 32-32 32s-32-13-32-32l0-160L320 685c-19 0-32-13-32-32 0-19 13-32 32-32l160 0L480 461c0-19 13-32 32-32s32 13 32 32l0 160L704 621c19 0 32 13 32 32C736 666 723 685 704 685zM890 326 102 326 102 250c0-32 32-64 64-64l659 0c38 0 64 32 64 64L890 326z</StreamGeometry>
160160
<StreamGeometry x:Key="Icons.Worktrees">M1182 527a91 91 0 00-88-117H92a91 91 0 00-88 117l137 441A80 80 0 00217 1024h752a80 80 0 0076-56zM133 295a31 31 0 0031 31h858a31 31 0 0031-31A93 93 0 00959 203H226a93 93 0 00-94 92zM359 123h467a31 31 0 0031-31A92 92 0 00765 0H421a92 92 0 00-92 92 31 31 0 0031 31z</StreamGeometry>
161+
<StreamGeometry x:Key="Icons.Unreal">M803.7,995.81c156.5-73.92,205.56-210.43,216.6-263.61c-57.22,58.6-120.53,118-163.11,76.88c0,0-2.33-219.45-2.33-309.43c0-121,114.75-211.18,114.75-211.18c-63.11,11.24-138.89,33.71-219.33,112.65c-7.26,7.2-14.14,14.76-20.62,22.67c-34.47-26.39-79.14-18.48-79.14-18.48c24.14,13.26,48.23,51.88,48.23,83.85v314.26c0,0-52.63,46.3-93.19,46.3c-9.14,0.07-18.17-2.05-26.33-6.18c-8.16-4.13-15.21-10.15-20.56-17.56c-3.21-4.19-5.87-8.78-7.91-13.65V424.07c-11.99,9.89-52.51,18.04-52.51-49.22c0-41.79,30.11-91.6,83.73-122.15c-73.63,11.23-142.59,43.04-198.92,91.76c-42.8,36.98-77.03,82.85-100.31,134.4c-23.28,51.55-35.06,107.55-34.51,164.12c0,0,39.21-122.51,88.32-133.83c7.15-1.88,14.65-2.07,21.89-0.54c7.24,1.53,14.02,4.72,19.81,9.34c5.79,4.61,10.41,10.51,13.51,17.23c3.1,6.72,4.59,14.07,4.34,21.46V844.3c0,29.16-18.8,35.53-36.17,35.22c-11.77-0.83-23.4-3.02-34.66-6.53c35.86,48.53,82.46,88.12,136.15,115.66c53.69,27.54,113.03,42.29,173.37,43.1l106.05-106.6L803.7,995.81z</StreamGeometry>
161162
</ResourceDictionary>

src/Resources/Locales/en_US.axaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,4 +978,6 @@
978978
<x:String x:Key="Text.Worktree.Remove" xml:space="preserve">Remove</x:String>
979979
<x:String x:Key="Text.Worktree.Unlock" xml:space="preserve">Unlock</x:String>
980980
<x:String x:Key="Text.Yes" xml:space="preserve">YES</x:String>
981+
<x:String x:Key="Text.Configure.OFPA.Enable" xml:space="preserve">Decode Unreal Engine OFPA file names</x:String>
982+
<x:String x:Key="Text.Configure.OFPA.Enable.Tip" xml:space="preserve">Show human-readable actor names instead of hash-based file names in __ExternalActors__ and __ExternalObjects__ folders. If decoding fails, raw paths remain unchanged.</x:String>
981983
</ResourceDictionary>

src/SourceGit.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,8 @@
6767
<TrimmerRootAssembly Include="SourceGit" />
6868
<TrimmerRootAssembly Include="Avalonia.Themes.Fluent" />
6969
</ItemGroup>
70+
71+
<ItemGroup>
72+
<InternalsVisibleTo Include="SourceGit.Tests" />
73+
</ItemGroup>
7074
</Project>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
using Avalonia.Threading;
8+
9+
using CommunityToolkit.Mvvm.ComponentModel;
10+
11+
namespace SourceGit.Utilities
12+
{
13+
/// <summary>
14+
/// Shared async decoding context for OFPA filenames.
15+
/// Handles scheduling, stale-guard, PropertyChanged notification,
16+
/// and reactive enable/disable in response to Repository.EnableOFPADecoding changes.
17+
/// </summary>
18+
internal sealed class OFPADecodingContext : ObservableObject, IDisposable
19+
{
20+
public IReadOnlyDictionary<string, string> DecodedPaths => _decodedPaths;
21+
22+
public OFPADecodingContext(ViewModels.Repository repo, Action onReEnabled)
23+
{
24+
_repo = repo;
25+
_onReEnabled = onReEnabled;
26+
_repo.PropertyChanged += OnRepositoryPropertyChanged;
27+
}
28+
29+
public void Dispose()
30+
{
31+
if (_repo != null)
32+
_repo.PropertyChanged -= OnRepositoryPropertyChanged;
33+
_repo = null;
34+
_onReEnabled = null;
35+
}
36+
37+
/// <summary>
38+
/// Schedule an async OFPA decode. The caller provides a factory
39+
/// that returns the decoded path map. Stale requests are discarded.
40+
/// </summary>
41+
public void ScheduleRefresh(Func<Task<Dictionary<string, string>>> lookupFactory)
42+
{
43+
var requestId = Interlocked.Increment(ref _requestId);
44+
_ = RunAsync(lookupFactory, requestId);
45+
}
46+
47+
public void Clear()
48+
{
49+
Interlocked.Increment(ref _requestId);
50+
_decodedPaths = null;
51+
OnPropertyChanged(nameof(DecodedPaths));
52+
}
53+
54+
private async Task RunAsync(Func<Task<Dictionary<string, string>>> lookupFactory, long requestId)
55+
{
56+
Dictionary<string, string> results = null;
57+
try
58+
{
59+
results = await lookupFactory().ConfigureAwait(false);
60+
}
61+
catch (Exception)
62+
{
63+
// Decode failures are non-fatal; raw paths remain visible.
64+
}
65+
66+
await Dispatcher.UIThread.InvokeAsync(() =>
67+
{
68+
if (_repo == null || !_repo.EnableOFPADecoding || requestId != Interlocked.Read(ref _requestId))
69+
return;
70+
71+
_decodedPaths = results;
72+
OnPropertyChanged(nameof(DecodedPaths));
73+
});
74+
}
75+
76+
private void OnRepositoryPropertyChanged(object sender, PropertyChangedEventArgs e)
77+
{
78+
if (e.PropertyName != nameof(ViewModels.Repository.EnableOFPADecoding))
79+
return;
80+
81+
if (_repo?.EnableOFPADecoding == true)
82+
_onReEnabled?.Invoke();
83+
else
84+
Clear();
85+
}
86+
87+
private ViewModels.Repository _repo;
88+
private Action _onReEnabled;
89+
private Dictionary<string, string> _decodedPaths;
90+
private long _requestId;
91+
}
92+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
using System;
2+
using System.IO;
3+
4+
namespace SourceGit.Utilities
5+
{
6+
internal static class OFPAFilePrefixReader
7+
{
8+
public static byte[] Read(string filePath, int maxBytes)
9+
{
10+
try
11+
{
12+
if (string.IsNullOrEmpty(filePath) || maxBytes <= 0 || !File.Exists(filePath))
13+
return null;
14+
15+
using var stream = File.OpenRead(filePath);
16+
var length = (int)Math.Min(stream.Length, maxBytes);
17+
if (length <= 0)
18+
return [];
19+
20+
var buffer = new byte[length];
21+
var offset = 0;
22+
while (offset < length)
23+
{
24+
var read = stream.Read(buffer, offset, length - offset);
25+
if (read <= 0)
26+
break;
27+
28+
offset += read;
29+
}
30+
31+
if (offset == length)
32+
return buffer;
33+
34+
if (offset == 0)
35+
return [];
36+
37+
var resized = new byte[offset];
38+
Array.Copy(buffer, resized, offset);
39+
return resized;
40+
}
41+
catch (Exception)
42+
{
43+
return null;
44+
}
45+
}
46+
}
47+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.IO;
5+
using System.Text;
6+
using System.Threading.Tasks;
7+
8+
namespace SourceGit.Utilities
9+
{
10+
internal static class OFPAGitBatchReader
11+
{
12+
public static async Task<Dictionary<string, byte[]>> ReadAsync(string repo, IReadOnlyList<string> objectSpecs, int maxBytesPerObject)
13+
{
14+
var results = new Dictionary<string, byte[]>(StringComparer.Ordinal);
15+
if (objectSpecs.Count == 0)
16+
return results;
17+
18+
var gitExecutable = string.IsNullOrEmpty(Native.OS.GitExecutable) ? "git" : Native.OS.GitExecutable;
19+
var starter = new ProcessStartInfo
20+
{
21+
WorkingDirectory = repo,
22+
FileName = gitExecutable,
23+
Arguments = "cat-file --batch",
24+
UseShellExecute = false,
25+
CreateNoWindow = true,
26+
WindowStyle = ProcessWindowStyle.Hidden,
27+
RedirectStandardInput = true,
28+
RedirectStandardOutput = true,
29+
};
30+
31+
try
32+
{
33+
using var proc = Process.Start(starter)!;
34+
var writeTask = Task.Run(async () =>
35+
{
36+
await using var input = proc.StandardInput;
37+
foreach (var spec in objectSpecs)
38+
await input.WriteLineAsync(spec).ConfigureAwait(false);
39+
});
40+
41+
await using var output = proc.StandardOutput.BaseStream;
42+
for (var i = 0; i < objectSpecs.Count; i++)
43+
{
44+
var header = await ReadHeaderLineAsync(output).ConfigureAwait(false);
45+
if (header == null)
46+
break;
47+
48+
if (header.EndsWith(" missing", StringComparison.Ordinal))
49+
continue;
50+
51+
var size = ParseObjectSize(header);
52+
if (size > 0)
53+
{
54+
var bytesToRead = maxBytesPerObject > 0 && size > maxBytesPerObject
55+
? maxBytesPerObject
56+
: size;
57+
var bytesToSkip = size - bytesToRead;
58+
var data = await ReadExactBytesAsync(output, bytesToRead).ConfigureAwait(false);
59+
if (data != null)
60+
results[objectSpecs[i]] = data;
61+
62+
if (bytesToSkip > 0)
63+
await SkipBytesAsync(output, bytesToSkip).ConfigureAwait(false);
64+
}
65+
66+
_ = await ReadSingleByteAsync(output).ConfigureAwait(false);
67+
}
68+
69+
await writeTask.ConfigureAwait(false);
70+
await proc.WaitForExitAsync().ConfigureAwait(false);
71+
}
72+
catch (Exception e)
73+
{
74+
App.RaiseException(repo, $"Failed to query OFPA batch file content: {e}");
75+
}
76+
77+
return results;
78+
}
79+
80+
private static int ParseObjectSize(string header)
81+
{
82+
var lastSpace = header.LastIndexOf(' ');
83+
if (lastSpace <= 0 || lastSpace == header.Length - 1)
84+
return 0;
85+
86+
return int.TryParse(header.AsSpan(lastSpace + 1), out var size) ? size : 0;
87+
}
88+
89+
private static async Task<string> ReadHeaderLineAsync(Stream stream)
90+
{
91+
var buffer = new MemoryStream();
92+
while (true)
93+
{
94+
var value = await ReadSingleByteAsync(stream).ConfigureAwait(false);
95+
if (value == -1)
96+
break;
97+
98+
if (value == '\n')
99+
break;
100+
101+
buffer.WriteByte((byte)value);
102+
}
103+
104+
if (buffer.Length == 0)
105+
return null;
106+
107+
var line = Encoding.ASCII.GetString(buffer.ToArray());
108+
return line.EndsWith('\r') ? line[..^1] : line;
109+
}
110+
111+
private static async Task<byte[]> ReadExactBytesAsync(Stream stream, int length)
112+
{
113+
var buffer = new byte[length];
114+
var totalRead = 0;
115+
while (totalRead < length)
116+
{
117+
var read = await stream.ReadAsync(buffer.AsMemory(totalRead, length - totalRead)).ConfigureAwait(false);
118+
if (read <= 0)
119+
return null;
120+
121+
totalRead += read;
122+
}
123+
124+
return buffer;
125+
}
126+
127+
private static async Task SkipBytesAsync(Stream stream, int length)
128+
{
129+
var buffer = new byte[Math.Min(length, 8192)];
130+
var remaining = length;
131+
while (remaining > 0)
132+
{
133+
var toRead = Math.Min(remaining, buffer.Length);
134+
var read = await stream.ReadAsync(buffer.AsMemory(0, toRead)).ConfigureAwait(false);
135+
if (read <= 0)
136+
break;
137+
138+
remaining -= read;
139+
}
140+
}
141+
142+
private static async Task<int> ReadSingleByteAsync(Stream stream)
143+
{
144+
var buffer = new byte[1];
145+
var read = await stream.ReadAsync(buffer.AsMemory(0, 1)).ConfigureAwait(false);
146+
return read == 0 ? -1 : buffer[0];
147+
}
148+
}
149+
}

0 commit comments

Comments
 (0)