Skip to content

Commit e3b7f72

Browse files
committed
Add initial PCP.
1 parent b7f326e commit e3b7f72

File tree

11 files changed

+337
-26
lines changed

11 files changed

+337
-26
lines changed

Penumbra.Api

Penumbra/Communication/ModPathChanged.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Penumbra.Api.Api;
44
using Penumbra.Mods;
55
using Penumbra.Mods.Manager;
6+
using Penumbra.Services;
67

78
namespace Penumbra.Communication;
89

@@ -20,11 +21,14 @@ public sealed class ModPathChanged()
2021
{
2122
public enum Priority
2223
{
24+
/// <seealso cref="PcpService.OnModPathChange"/>
25+
PcpService = int.MinValue,
26+
2327
/// <seealso cref="ModsApi.OnModPathChange"/>
24-
ApiMods = int.MinValue,
28+
ApiMods = int.MinValue + 1,
2529

2630
/// <seealso cref="ModSettingsApi.OnModPathChange"/>
27-
ApiModSettings = int.MinValue,
31+
ApiModSettings = int.MinValue + 1,
2832

2933
/// <seealso cref="EphemeralConfig.OnModPathChanged"/>
3034
EphemeralConfig = -500,

Penumbra/Configuration.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public bool EnableMods
8888
public bool OpenFoldersByDefault { get; set; } = false;
8989
public int SingleGroupRadioMax { get; set; } = 2;
9090
public string DefaultImportFolder { get; set; } = string.Empty;
91+
public string PcpFolderName { get; set; } = "PCP";
9192
public string QuickMoveFolder1 { get; set; } = string.Empty;
9293
public string QuickMoveFolder2 { get; set; } = string.Empty;
9394
public string QuickMoveFolder3 { get; set; } = string.Empty;

Penumbra/Import/TexToolsImport.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ private void ImportFiles()
119119
// Puts out warnings if extension does not correspond to data.
120120
private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile)
121121
{
122-
if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".zip" or ".7z" or ".rar")
122+
if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".pcp" or ".zip" or ".7z" or ".rar")
123123
return HandleRegularArchive(modPackFile);
124124

125125
using var zfs = modPackFile.OpenRead();

Penumbra/Mods/Manager/ModDataEditor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ public SaveService SaveService
3636

3737
/// <summary> Create the file containing the meta information about a mod from scratch. </summary>
3838
public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version,
39-
string? website)
39+
string? website, params string[] tags)
4040
{
4141
var mod = new Mod(directory);
4242
mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name);
4343
mod.Author = author != null ? new LowerString(author) : mod.Author;
4444
mod.Description = description ?? mod.Description;
4545
mod.Version = version ?? mod.Version;
4646
mod.Website = website ?? mod.Website;
47+
mod.ModTags = tags;
4748
saveService.ImmediateSaveSync(new ModMeta(mod));
4849
}
4950

Penumbra/Mods/ModCreator.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ public partial class ModCreator(
3232
public readonly Configuration Config = config;
3333

3434
/// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary>
35-
public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null)
35+
public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null, params string[] tags)
3636
{
3737
try
3838
{
3939
var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true);
40-
dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty);
40+
dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty, tags);
4141
CreateDefaultFiles(newDir);
4242
return newDir;
4343
}

Penumbra/Services/PcpService.cs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
using System.Buffers.Text;
2+
using Dalamud.Game.ClientState.Objects.Types;
3+
using FFXIVClientStructs.FFXIV.Client.Game.Object;
4+
using Newtonsoft.Json;
5+
using Newtonsoft.Json.Linq;
6+
using OtterGui.Classes;
7+
using OtterGui.Services;
8+
using Penumbra.Collections;
9+
using Penumbra.Collections.Manager;
10+
using Penumbra.Communication;
11+
using Penumbra.GameData.Actors;
12+
using Penumbra.GameData.Interop;
13+
using Penumbra.GameData.Structs;
14+
using Penumbra.Interop.PathResolving;
15+
using Penumbra.Interop.ResourceTree;
16+
using Penumbra.Meta.Manipulations;
17+
using Penumbra.Mods;
18+
using Penumbra.Mods.Groups;
19+
using Penumbra.Mods.Manager;
20+
using Penumbra.Mods.SubMods;
21+
using Penumbra.String.Classes;
22+
23+
namespace Penumbra.Services;
24+
25+
public class PcpService : IApiService, IDisposable
26+
{
27+
public const string Extension = ".pcp";
28+
29+
private readonly Configuration _config;
30+
private readonly SaveService _files;
31+
private readonly ResourceTreeFactory _treeFactory;
32+
private readonly ObjectManager _objectManager;
33+
private readonly ActorManager _actors;
34+
private readonly FrameworkManager _framework;
35+
private readonly CollectionResolver _collectionResolver;
36+
private readonly CollectionManager _collections;
37+
private readonly ModCreator _modCreator;
38+
private readonly ModExportManager _modExport;
39+
private readonly CommunicatorService _communicator;
40+
private readonly SHA1 _sha1 = SHA1.Create();
41+
private readonly ModFileSystem _fileSystem;
42+
43+
public PcpService(Configuration config,
44+
SaveService files,
45+
ResourceTreeFactory treeFactory,
46+
ObjectManager objectManager,
47+
ActorManager actors,
48+
FrameworkManager framework,
49+
CollectionManager collections,
50+
CollectionResolver collectionResolver,
51+
ModCreator modCreator,
52+
ModExportManager modExport,
53+
CommunicatorService communicator,
54+
ModFileSystem fileSystem)
55+
{
56+
_config = config;
57+
_files = files;
58+
_treeFactory = treeFactory;
59+
_objectManager = objectManager;
60+
_actors = actors;
61+
_framework = framework;
62+
_collectionResolver = collectionResolver;
63+
_collections = collections;
64+
_modCreator = modCreator;
65+
_modExport = modExport;
66+
_communicator = communicator;
67+
_fileSystem = fileSystem;
68+
69+
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService);
70+
}
71+
72+
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory)
73+
{
74+
if (type is not ModPathChangeType.Added || newDirectory is null)
75+
return;
76+
77+
try
78+
{
79+
var file = Path.Combine(newDirectory.FullName, "collection.json");
80+
if (!File.Exists(file))
81+
return;
82+
83+
var text = File.ReadAllText(file);
84+
var jObj = JObject.Parse(text);
85+
var identifier = _actors.FromJson(jObj["Actor"] as JObject);
86+
if (!identifier.IsValid)
87+
return;
88+
89+
if (jObj["Collection"]?.ToObject<string>() is not { } collectionName)
90+
return;
91+
92+
var name = $"PCP/{collectionName}";
93+
if (!_collections.Storage.AddCollection(name, null))
94+
return;
95+
96+
var collection = _collections.Storage[^1];
97+
_collections.Editor.SetModState(collection, mod, true);
98+
99+
var identifierGroup = _collections.Active.Individuals.GetGroup(identifier);
100+
_collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup);
101+
if (_fileSystem.TryGetValue(mod, out var leaf))
102+
{
103+
try
104+
{
105+
var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpFolderName);
106+
_fileSystem.Move(leaf, folder);
107+
}
108+
catch
109+
{
110+
// ignored.
111+
}
112+
}
113+
}
114+
catch (Exception ex)
115+
{
116+
Penumbra.Log.Error($"Error reading the collection.json file from {mod.Identifier}:\n{ex}");
117+
}
118+
}
119+
120+
public void Dispose()
121+
=> _communicator.ModPathChanged.Unsubscribe(OnModPathChange);
122+
123+
public async Task<(bool, string)> CreatePcp(ObjectIndex objectIndex, string note = "", CancellationToken cancel = default)
124+
{
125+
try
126+
{
127+
var (identifier, tree, collection) = await _framework.Framework.RunOnFrameworkThread(() =>
128+
{
129+
var (actor, identifier) = CheckActor(objectIndex);
130+
cancel.ThrowIfCancellationRequested();
131+
unsafe
132+
{
133+
var collection = _collectionResolver.IdentifyCollection((GameObject*)actor.Address, true);
134+
if (!collection.Valid || !collection.ModCollection.HasCache)
135+
throw new Exception($"Actor {identifier} has no mods applying, nothing to do.");
136+
137+
cancel.ThrowIfCancellationRequested();
138+
if (_treeFactory.FromCharacter(actor, 0) is not { } tree)
139+
throw new Exception($"Unable to fetch modded resources for {identifier}.");
140+
141+
return (identifier.CreatePermanent(), tree, collection);
142+
}
143+
});
144+
cancel.ThrowIfCancellationRequested();
145+
var time = DateTime.Now;
146+
var modDirectory = CreateMod(identifier, note, time);
147+
await CreateDefaultMod(modDirectory, collection.ModCollection, tree, cancel);
148+
await CreateCollectionInfo(modDirectory, identifier, note, time, cancel);
149+
var file = ZipUp(modDirectory);
150+
return (true, file);
151+
}
152+
catch (Exception ex)
153+
{
154+
return (false, ex.Message);
155+
}
156+
}
157+
158+
private static string ZipUp(DirectoryInfo directory)
159+
{
160+
var fileName = directory.FullName + Extension;
161+
ZipFile.CreateFromDirectory(directory.FullName, fileName, CompressionLevel.Optimal, false);
162+
directory.Delete(true);
163+
return fileName;
164+
}
165+
166+
private static async Task CreateCollectionInfo(DirectoryInfo directory, ActorIdentifier actor, string note, DateTime time,
167+
CancellationToken cancel = default)
168+
{
169+
var jObj = new JObject
170+
{
171+
["Version"] = 1,
172+
["Actor"] = actor.ToJson(),
173+
["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(),
174+
["Time"] = time,
175+
["Note"] = note,
176+
};
177+
if (note.Length > 0)
178+
cancel.ThrowIfCancellationRequested();
179+
var filePath = Path.Combine(directory.FullName, "collection.json");
180+
await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew);
181+
await using var stream = new StreamWriter(file);
182+
await using var json = new JsonTextWriter(stream);
183+
json.Formatting = Formatting.Indented;
184+
await jObj.WriteToAsync(json, cancel);
185+
}
186+
187+
private DirectoryInfo CreateMod(ActorIdentifier actor, string note, DateTime time)
188+
{
189+
var directory = _modExport.ExportDirectory;
190+
directory.Create();
191+
var actorName = actor.ToName();
192+
var authorName = _actors.GetCurrentPlayer().ToName();
193+
var suffix = note.Length > 0
194+
? note
195+
: time.ToString("yyyy-MM-ddTHH\\:mm", CultureInfo.InvariantCulture);
196+
var modName = $"{actorName} - {suffix}";
197+
var description = $"On-Screen Data for {actorName} as snapshotted on {time}.";
198+
return _modCreator.CreateEmptyMod(directory, modName, description, authorName, "PCP")
199+
?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}.");
200+
}
201+
202+
private async Task CreateDefaultMod(DirectoryInfo modDirectory, ModCollection collection, ResourceTree tree,
203+
CancellationToken cancel = default)
204+
{
205+
var subDirectory = modDirectory.CreateSubdirectory("files");
206+
var subMod = new DefaultSubMod(null!);
207+
foreach (var node in tree.FlatNodes)
208+
{
209+
cancel.ThrowIfCancellationRequested();
210+
var gamePath = node.GamePath;
211+
var fullPath = node.FullPath;
212+
if (fullPath.IsRooted)
213+
{
214+
var hash = await _sha1.ComputeHashAsync(File.OpenRead(fullPath.FullName), cancel).ConfigureAwait(false);
215+
cancel.ThrowIfCancellationRequested();
216+
var name = Convert.ToHexString(hash) + fullPath.Extension;
217+
var newFile = Path.Combine(subDirectory.FullName, name);
218+
if (!File.Exists(newFile))
219+
File.Copy(fullPath.FullName, newFile);
220+
subMod.Files.TryAdd(gamePath, new FullPath(newFile));
221+
}
222+
else if (gamePath.Path != fullPath.InternalName)
223+
{
224+
subMod.FileSwaps.TryAdd(gamePath, fullPath);
225+
}
226+
}
227+
228+
cancel.ThrowIfCancellationRequested();
229+
subMod.Manipulations = new MetaDictionary(collection.MetaCache);
230+
231+
var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport);
232+
var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport);
233+
cancel.ThrowIfCancellationRequested();
234+
await using var fileStream = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew);
235+
await using var writer = new StreamWriter(fileStream);
236+
saveGroup.Save(writer);
237+
}
238+
239+
private (ICharacter Actor, ActorIdentifier Identifier) CheckActor(ObjectIndex objectIndex)
240+
{
241+
var actor = _objectManager[objectIndex];
242+
if (!actor.Valid)
243+
throw new Exception($"No Actor at index {objectIndex} found.");
244+
245+
if (!actor.Identifier(_actors, out var identifier))
246+
throw new Exception($"Could not create valid identifier for actor at index {objectIndex}.");
247+
248+
if (!actor.IsCharacter)
249+
throw new Exception($"Actor {identifier} at index {objectIndex} is not a valid character.");
250+
251+
if (!actor.Model.Valid)
252+
throw new Exception($"Actor {identifier} at index {objectIndex} has no model.");
253+
254+
if (_objectManager.Objects.CreateObjectReference(actor.Address) is not ICharacter character)
255+
throw new Exception($"Actor {identifier} at index {objectIndex} could not be converted to ICharacter");
256+
257+
return (character, identifier);
258+
}
259+
}

0 commit comments

Comments
 (0)