Compare commits

...

131 commits

Author SHA1 Message Date
Karou
ccb5b01290 Api version bump and remove redundant framework thread call
Some checks failed
.NET Build / build (push) Has been cancelled
2025-12-05 13:39:19 +01:00
Actions User
5dd74297c6 [CI] Updating repo.json for 1.5.1.8
Some checks failed
.NET Build / build (push) Has been cancelled
2025-11-28 22:10:17 +00:00
Karou
ce54aa5d25 Added IPC call to allow for redrawing only members of specified collections
Some checks failed
.NET Build / build (push) Has been cancelled
2025-11-03 15:15:40 +01:00
Actions User
c4b6e4e00b [CI] Updating repo.json for testing_1.5.1.7
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-23 21:50:20 +00:00
Ottermandias
912c183fc6 Improve file watcher. 2025-10-23 23:45:20 +02:00
Ottermandias
5bf901d0c4 Update actorobjectmanager when setting cutscene index.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-23 17:30:29 +02:00
Ottermandias
cbedc878b9 Slight cleanup and autoformat. 2025-10-22 21:56:16 +02:00
Ottermandias
c8cf560fc1 Merge branch 'refs/heads/StoiaCode/fileWatcher' 2025-10-22 21:48:42 +02:00
Stoia
f05cb52da2 Add Option to notify instead of auto install.
And General Fixes
2025-10-22 18:20:44 +02:00
Ottermandias
7ed81a9823 Update OtterGui.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-10-22 17:53:02 +02:00
Stoia
60aa23efcd
Merge branch 'xivdev:master' into fileWatcher 2025-10-22 14:28:08 +02:00
Ottermandias
ebbe957c95 Remove login screen log spam.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-11 20:13:51 +02:00
Actions User
300e0e6d84 [CI] Updating repo.json for 1.5.1.6
Some checks failed
.NET Build / build (push) Has been cancelled
2025-10-07 10:45:04 +00:00
Ottermandias
049baa4fe4 Again. 2025-10-07 12:42:54 +02:00
Ottermandias
0881dfde8a Update signatures. 2025-10-07 12:27:35 +02:00
Actions User
23c0506cb8 [CI] Updating repo.json for testing_1.5.1.5
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-28 10:43:01 +00:00
Ottermandias
699745413e Make priority an int. 2025-09-28 12:40:52 +02:00
Actions User
eb53f04c6b [CI] Updating repo.json for testing_1.5.1.4
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-27 12:03:35 +00:00
Ottermandias
c6b596169c Add default constructor. 2025-09-27 14:01:21 +02:00
Actions User
a0c3e820b0 [CI] Updating repo.json for testing_1.5.1.3
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-27 11:02:39 +00:00
Ottermandias
a59689ebfe CS API update and add http API routes. 2025-09-27 13:00:18 +02:00
Exter-N
e9f67a009b Lift "shaders known" restriction for saving materials
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-19 11:18:39 +02:00
Ottermandias
97c8d82b33 Prevent default-named collection from being renamed and always put it at the top of the selector.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-07 10:45:28 +02:00
Stoia
c3b00ff426 Integrate FileWatcher
HEAVY WIP
2025-09-06 14:22:18 +02:00
Actions User
6348c4a639 [CI] Updating repo.json for 1.5.1.2
Some checks failed
.NET Build / build (push) Has been cancelled
2025-09-02 14:25:55 +00:00
Ottermandias
5a6e06df3b git is stupid 2025-09-02 16:22:02 +02:00
Ottermandias
f5f6dd3246 Handle some TODOs. 2025-09-02 16:12:01 +02:00
Ottermandias
4e788f7c2b Update sig. 2025-09-02 11:51:59 +02:00
Ottermandias
ad1659caf6 Update libraries. 2025-09-02 11:29:58 +02:00
Ottermandias
18a6ce2a5f Merge branch 'refs/heads/Exter-N/cldapi'
Some checks are pending
.NET Build / build (push) Waiting to run
2025-09-01 15:59:26 +02:00
Ottermandias
e68e821b2a Merge branch 'master' into Exter-N/cldapi 2025-09-01 15:58:22 +02:00
Ottermandias
96764b34ca Merge branch 'refs/heads/Exter-N/restree-stuff' 2025-09-01 15:57:06 +02:00
Exter-N
2cf60b78cd Reject and warn about cloud-synced base directories 2025-08-31 06:42:45 +02:00
Exter-N
d59be1e660 Refine IsCloudSynced 2025-08-31 05:25:37 +02:00
Exter-N
5503bb32e0 CloudApi testing in Debug tab 2025-08-31 04:13:56 +02:00
Exter-N
f3ec4b2e08 Only display the file name and last dir for externals 2025-08-30 19:19:07 +02:00
Exter-N
b3379a9710 Stop redacting external paths 2025-08-30 16:55:20 +02:00
Exter-N
8c25ef4b47 Make the save button ResourceTreeViewer baseline 2025-08-30 16:53:12 +02:00
Ottermandias
912020cc3f Update for staging and wrong tooltip.
Some checks failed
.NET Build / build (push) Has been cancelled
2025-08-29 16:36:42 +02:00
Ottermandias
be8987a451 Merge branch 'master' of github.com:xivDev/Penumbra
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-28 18:52:29 +02:00
Ottermandias
f7cf5503bb Fix deleting PCP collections. 2025-08-28 18:52:06 +02:00
Ottermandias
a04a5a071c Add warning in file redirections if extension doesn't match. 2025-08-28 18:51:57 +02:00
Actions User
71e24c13c7 [CI] Updating repo.json for 1.5.1.0
Some checks failed
.NET Build / build (push) Has been cancelled
2025-08-25 08:39:42 +00:00
Ottermandias
c0120f81af 1.5.1.0 2025-08-25 10:37:38 +02:00
Ottermandias
da47c19aeb Woops, increment version. 2025-08-25 10:25:05 +02:00
Actions User
e16800f216 [CI] Updating repo.json for testing_1.5.0.10 2025-08-25 08:16:04 +00:00
Ottermandias
79a4fc5904 Fix wrong logging. 2025-08-25 10:13:48 +02:00
Ottermandias
bf90725dd2 Fix resolvecontext issue. 2025-08-25 10:13:39 +02:00
Ottermandias
a14347f73a Update temporary collection creation. 2025-08-25 10:13:31 +02:00
Actions User
1e07e43498 [CI] Updating repo.json for testing_1.5.0.9
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-24 13:51:43 +00:00
Ottermandias
f51f8a7bf8 Try to filter meta entries for relevance. 2025-08-24 15:24:57 +02:00
Exter-N
1fca78fa71 Add Kdb files to ResourceTree 2025-08-24 14:09:02 +02:00
Exter-N
c8b6325a87 Add game integrity message to On-Screen 2025-08-24 14:06:39 +02:00
Ottermandias
6079103505 Add collection PCP settings.
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-23 14:46:27 +02:00
Actions User
d302a17f1f [CI] Updating repo.json for testing_1.5.0.8
Some checks are pending
.NET Build / build (push) Waiting to run
2025-08-22 18:33:43 +00:00
Ottermandias
0d64384059 Add cleanup buttons to PCP, add option to turn off PCP IPC. 2025-08-22 20:31:40 +02:00
Ottermandias
10894d451a Add Pcp Events. 2025-08-22 18:08:22 +02:00
Actions User
fb34238530 [CI] Updating repo.json for testing_1.5.0.7 2025-08-22 13:51:50 +00:00
Ottermandias
8043e6fb6b Add option to disable PCP. 2025-08-22 15:49:15 +02:00
Ottermandias
e3b7f72893 Add initial PCP. 2025-08-22 15:44:33 +02:00
Ottermandias
b7f326e29c Fix bug with collection setting and empty collection. 2025-08-22 15:43:55 +02:00
Ottermandias
dad01e1af8 Update GameData. 2025-08-20 15:24:00 +02:00
Ottermandias
10b71930a1 Merge branch 'refs/heads/Exter-N/stockings-skin-slot' 2025-08-18 15:41:22 +02:00
Ottermandias
23257f94a4 Some cleanup and add option to disable skin material attribute scanning. 2025-08-18 15:41:10 +02:00
Ottermandias
83a36ed4cb Merge branch 'master' into Exter-N/stockings-skin-slot 2025-08-18 15:31:52 +02:00
Ottermandias
8304579d29 Add predefined tags to the multi mod selector. 2025-08-17 13:54:36 +02:00
Actions User
24cbc6c5e1 [CI] Updating repo.json for 1.5.0.6 2025-08-17 08:46:26 +00:00
Exter-N
41edc23820 Allow changing the skin mtrl suffix 2025-08-17 03:11:11 +02:00
Exter-N
aa920b5e9b Fix ImGui texture usage issue 2025-08-17 01:41:49 +02:00
Ottermandias
87ace28bcf Update OtterGui. 2025-08-16 11:56:24 +02:00
Ottermandias
5917f5fad1 Small fixes. 2025-08-13 17:42:45 +02:00
Actions User
f69c264317 [CI] Updating repo.json for 1.5.0.5 2025-08-13 14:53:11 +00:00
Ottermandias
a7246b9d98 Add PBD Post-Processor that appends EPBD data if the loaded PBD does not contain it. 2025-08-13 16:50:26 +02:00
Actions User
9aff388e21 [CI] Updating repo.json for 1.5.0.4 2025-08-12 12:53:33 +00:00
Ottermandias
091aff1b8a Merge branch 'master' of github.com:xivDev/Penumbra 2025-08-12 14:47:50 +02:00
Ottermandias
9f8185f67b Add new parameter to LoadWeapon hook. 2025-08-12 14:47:35 +02:00
Actions User
b112d75a27 [CI] Updating repo.json for 1.5.0.3 2025-08-12 10:31:13 +00:00
Ottermandias
7af81a6c18 Fix issue with removing default metadata. 2025-08-12 12:29:09 +02:00
Ottermandias
12a218bb2b Protect against empty requested paths. 2025-08-12 12:28:56 +02:00
Ottermandias
f6bac93db7 Update ChangedEquipData. 2025-08-11 19:58:24 +02:00
Actions User
155d3d49aa [CI] Updating repo.json for 1.5.0.2 2025-08-09 16:40:42 +00:00
Exter-N
9aae2210a2 Fix nullptr crashes 2025-08-09 14:57:15 +02:00
Actions User
3785a629ce [CI] Updating repo.json for 1.5.0.1 2025-08-09 11:03:24 +00:00
Ottermandias
02af52671f Need staging again ... 2025-08-09 13:00:40 +02:00
Ottermandias
391c9d727e Fix shifted timeline vfunc offset. 2025-08-09 12:51:39 +02:00
Ottermandias
ff2b2be953 Fix popups not working early. 2025-08-09 12:11:29 +02:00
Ottermandias
6242b30f93 Fix resizable child. 2025-08-09 11:58:35 +02:00
Exter-N
11cd08a9de ClientStructs-ify stuff 2025-08-09 10:29:29 +02:00
Ottermandias
46cfbcb115 Set Repo API level to 13 and remove stg from future releases. 2025-08-08 23:13:23 +02:00
Actions User
66543cc671 [CI] Updating repo.json for 1.5.0.0 2025-08-08 21:12:00 +00:00
Ottermandias
13283c9690 Fix dumb. 2025-08-08 23:08:26 +02:00
Ottermandias
bedfb22466 Use staging for release. 2025-08-08 23:04:50 +02:00
Ottermandias
13df8b2248 Update gamedata. 2025-08-08 23:02:22 +02:00
Ottermandias
93406e4d4e 1.5.0.0 2025-08-08 16:17:59 +02:00
Ridan Vandenbergh
8140d08557 Add vertex material types for usages of 2 colour attributes 2025-08-08 16:15:19 +02:00
Ridan Vandenbergh
2b36f39848 Fix basecolor texture in material export 2025-08-08 16:12:37 +02:00
Ottermandias
a69811800d Update GameData 2025-08-08 15:56:25 +02:00
Ottermandias
3f18ad50de Initial API13 / 7.3 update. 2025-08-08 00:45:24 +02:00
Ridan Vandenbergh
6689e326ee Material tab: disallow "Enable Transparency" for stockings shader 2025-08-02 00:38:24 +02:00
Passive
bdcab22a55 Cleanup methods to extension class 2025-08-02 00:16:55 +02:00
Passive
f5f4fe7259 Invalid tangent fix example 2025-08-02 00:16:55 +02:00
Sebastina
898963fea5 Allow focusing a specified mod via HTTP API under the mods tab. 2025-08-02 00:16:38 +02:00
Ottermandias
8527bfa29c Fix missing updates for OtterGui. 2025-08-02 00:13:35 +02:00
Ottermandias
baca3cdec2 Update Libs. 2025-08-02 00:08:09 +02:00
Ottermandias
dc93eba34c Add initial complex group things. 2025-08-02 00:06:25 +02:00
Ottermandias
012052daa0 Change behavior for directory names. 2025-08-02 00:06:03 +02:00
Ottermandias
a9546e31ee Update packages. 2025-08-02 00:05:27 +02:00
Actions User
a4a6283e7b [CI] Updating repo.json for testing_1.4.0.6 2025-07-14 15:12:06 +00:00
Ottermandias
00c02fd16e Fix tex file migration for small textures. 2025-07-14 17:09:07 +02:00
Ottermandias
140d150bb4 Fix character sound data. 2025-07-14 17:08:46 +02:00
Actions User
49a6d935f3 [CI] Updating repo.json for testing_1.4.0.5 2025-07-05 20:11:28 +00:00
Ottermandias
692beacc2e Merge remote-tracking branch 'Exter-N/human-skin-materials' 2025-07-05 22:04:48 +02:00
Ottermandias
a953febfba Add support for imc-toggle attributes to accessories, and fix up attributes when item swapping models. 2025-07-05 22:03:32 +02:00
Ottermandias
c0aa2e36ea Merge branch 'refs/heads/Exter-N/reslogger-tid' 2025-07-05 22:03:14 +02:00
Exter-N
278bf43b29 ClientStructs-ify ResourceTree stuff 2025-07-05 05:20:24 +02:00
Exter-N
a97d9e4953 Add Human skin material handling 2025-07-05 04:37:37 +02:00
Exter-N
30e3cd1f38 Add OS thread ID info to the Resource Logger 2025-07-04 19:41:31 +02:00
Ottermandias
62e9dc164d Add support button. 2025-06-26 14:49:28 +02:00
Actions User
9fc572ba0c [CI] Updating repo.json for testing_1.4.0.4 2025-06-15 21:47:41 +00:00
Ottermandias
3c20b541ce Make mousewheel-scrolling work for setting combos, also filters. 2025-06-15 23:20:13 +02:00
Ottermandias
1961b03d37 Fix issues with shapes and attributes with ID. 2025-06-15 23:18:46 +02:00
Ottermandias
1f4ec984b3 Use improved filesystem. 2025-06-13 17:27:56 +02:00
Ottermandias
4981b0348f BNPCs. 2025-06-13 17:27:56 +02:00
Ottermandias
a8c05fc6ee Make middle-mouse button handle temporary settings. 2025-06-13 17:27:56 +02:00
Actions User
3d05662384 [CI] Updating repo.json for testing_1.4.0.3 2025-06-08 09:38:30 +00:00
Ottermandias
973814b31b Some more BNPCs. 2025-06-08 11:36:32 +02:00
Ottermandias
a16fd85a7e Handle .tex files with broken mip map offsets on import, also remove unnecessary mipmaps (any after reaching minimum size once). 2025-06-08 11:28:12 +02:00
Ottermandias
4c0e6d2a67 Update Mod Merger for other group types. 2025-06-07 22:10:59 +02:00
Ottermandias
535694e9c8 Update some BNPC Names. 2025-06-07 22:10:17 +02:00
Ottermandias
318a41fe52 Add checking for supported features with the currently new supported features 'Atch', 'Shp' and 'Atr'. 2025-06-03 18:39:54 +02:00
Actions User
98203e4e8a [CI] Updating repo.json for testing_1.4.0.2 2025-06-01 11:06:37 +00:00
196 changed files with 3882 additions and 686 deletions

View file

@ -20,7 +20,7 @@ jobs:
run: dotnet restore run: dotnet restore
- name: Download Dalamud - name: Download Dalamud
run: | run: |
Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/latest.zip -OutFile latest.zip Invoke-WebRequest -Uri https://goatcorp.github.io/dalamud-distrib/stg/latest.zip -OutFile latest.zip
Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev" Expand-Archive -Force latest.zip "$env:AppData\XIVLauncher\addon\Hooks\dev"
- name: Build - name: Build
run: | run: |

@ -1 +1 @@
Subproject commit 17a3ee5711ca30eb7f5b393dfb8136f0bce49b2b Subproject commit a63f6735cf4bed4f7502a022a10378607082b770

@ -1 +1 @@
Subproject commit 1465203967d08519c6716292bc5e5094c7fbcacc Subproject commit 3d6cee1a11922ccd426f36060fd026bc1a698adf

View file

@ -1,4 +1,4 @@
<Project Sdk="Dalamud.NET.Sdk/12.0.2"> <Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
</PropertyGroup> </PropertyGroup>

@ -1 +1 @@
Subproject commit 14b3641f0fb520cb829ceb50fa7cb31255a1da4e Subproject commit d889f9ef918514a46049725052d378b441915b00

@ -1 +1 @@
Subproject commit 0e5dcd1a5687ec5f8fa2ef2526b94b9a0ea1b5b5 Subproject commit c8611a0c546b6b2ec29214ab319fc2c38fe74793

View file

@ -0,0 +1,7 @@
namespace Penumbra.Api.Api;
public static class IdentityChecker
{
public static bool Check(string identity)
=> true;
}

View file

@ -1,3 +1,4 @@
using Newtonsoft.Json.Linq;
using OtterGui.Compression; using OtterGui.Compression;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
@ -33,12 +34,8 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
{ {
switch (type) switch (type)
{ {
case ModPathChangeType.Deleted when oldDirectory != null: case ModPathChangeType.Deleted when oldDirectory != null: ModDeleted?.Invoke(oldDirectory.Name); break;
ModDeleted?.Invoke(oldDirectory.Name); case ModPathChangeType.Added when newDirectory != null: ModAdded?.Invoke(newDirectory.Name); break;
break;
case ModPathChangeType.Added when newDirectory != null:
ModAdded?.Invoke(newDirectory.Name);
break;
case ModPathChangeType.Moved when newDirectory != null && oldDirectory != null: case ModPathChangeType.Moved when newDirectory != null && oldDirectory != null:
ModMoved?.Invoke(oldDirectory.Name, newDirectory.Name); ModMoved?.Invoke(oldDirectory.Name, newDirectory.Name);
break; break;
@ -46,7 +43,9 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
} }
public void Dispose() public void Dispose()
=> _communicator.ModPathChanged.Unsubscribe(OnModPathChanged); {
_communicator.ModPathChanged.Unsubscribe(OnModPathChanged);
}
public Dictionary<string, string> GetModList() public Dictionary<string, string> GetModList()
=> _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text); => _modManager.ToDictionary(m => m.ModPath.Name, m => m.Name.Text);
@ -109,10 +108,22 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
public event Action<string>? ModAdded; public event Action<string>? ModAdded;
public event Action<string, string>? ModMoved; public event Action<string, string>? ModMoved;
public event Action<JObject, ushort, string>? CreatingPcp
{
add => _communicator.PcpCreation.Subscribe(value!, PcpCreation.Priority.ModsApi);
remove => _communicator.PcpCreation.Unsubscribe(value!);
}
public event Action<JObject, string, Guid>? ParsingPcp
{
add => _communicator.PcpParsing.Subscribe(value!, PcpParsing.Priority.ModsApi);
remove => _communicator.PcpParsing.Unsubscribe(value!);
}
public (PenumbraApiEc, string, bool, bool) GetModPath(string modDirectory, string modName) public (PenumbraApiEc, string, bool, bool) GetModPath(string modDirectory, string modName)
{ {
if (!_modManager.TryGetMod(modDirectory, modName, out var mod) if (!_modManager.TryGetMod(modDirectory, modName, out var mod)
|| !_modFileSystem.FindLeaf(mod, out var leaf)) || !_modFileSystem.TryGetValue(mod, out var leaf))
return (PenumbraApiEc.ModMissing, string.Empty, false, false); return (PenumbraApiEc.ModMissing, string.Empty, false, false);
var fullPath = leaf.FullName(); var fullPath = leaf.FullName();
@ -127,7 +138,7 @@ public class ModsApi : IPenumbraApiMods, IApiService, IDisposable
return PenumbraApiEc.InvalidArgument; return PenumbraApiEc.InvalidArgument;
if (!_modManager.TryGetMod(modDirectory, modName, out var mod) if (!_modManager.TryGetMod(modDirectory, modName, out var mod)
|| !_modFileSystem.FindLeaf(mod, out var leaf)) || !_modFileSystem.TryGetValue(mod, out var leaf))
return PenumbraApiEc.ModMissing; return PenumbraApiEc.ModMissing;
try try

View file

@ -17,7 +17,7 @@ public class PenumbraApi(
UiApi ui) : IDisposable, IApiService, IPenumbraApi UiApi ui) : IDisposable, IApiService, IPenumbraApi
{ {
public const int BreakingVersion = 5; public const int BreakingVersion = 5;
public const int FeatureVersion = 9; public const int FeatureVersion = 13;
public void Dispose() public void Dispose()
{ {

View file

@ -1,39 +1,38 @@
using System.Collections.Frozen;
using Newtonsoft.Json; using Newtonsoft.Json;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Communication; using Penumbra.Communication;
using Penumbra.Mods;
using Penumbra.Services; using Penumbra.Services;
namespace Penumbra.Api.Api; namespace Penumbra.Api.Api;
public class PluginStateApi : IPenumbraApiPluginState, IApiService public class PluginStateApi(Configuration config, CommunicatorService communicator) : IPenumbraApiPluginState, IApiService
{ {
private readonly Configuration _config;
private readonly CommunicatorService _communicator;
public PluginStateApi(Configuration config, CommunicatorService communicator)
{
_config = config;
_communicator = communicator;
}
public string GetModDirectory() public string GetModDirectory()
=> _config.ModDirectory; => config.ModDirectory;
public string GetConfiguration() public string GetConfiguration()
=> JsonConvert.SerializeObject(_config, Formatting.Indented); => JsonConvert.SerializeObject(config, Formatting.Indented);
public event Action<string, bool>? ModDirectoryChanged public event Action<string, bool>? ModDirectoryChanged
{ {
add => _communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api); add => communicator.ModDirectoryChanged.Subscribe(value!, Communication.ModDirectoryChanged.Priority.Api);
remove => _communicator.ModDirectoryChanged.Unsubscribe(value!); remove => communicator.ModDirectoryChanged.Unsubscribe(value!);
} }
public bool GetEnabledState() public bool GetEnabledState()
=> _config.EnableMods; => config.EnableMods;
public event Action<bool>? EnabledChange public event Action<bool>? EnabledChange
{ {
add => _communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api); add => communicator.EnabledChanged.Subscribe(value!, EnabledChanged.Priority.Api);
remove => _communicator.EnabledChanged.Unsubscribe(value!); remove => communicator.EnabledChanged.Unsubscribe(value!);
} }
public FrozenSet<string> SupportedFeatures
=> FeatureChecker.SupportedFeatures.ToFrozenSet();
public string[] CheckSupportedFeatures(IEnumerable<string> requiredFeatures)
=> requiredFeatures.Where(f => !FeatureChecker.Supported(f)).ToArray();
} }

View file

@ -2,11 +2,14 @@ using Dalamud.Game.ClientState.Objects.Types;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.GameData.Interop;
using Penumbra.Interop.Services; using Penumbra.Interop.Services;
namespace Penumbra.Api.Api; namespace Penumbra.Api.Api;
public class RedrawApi(RedrawService redrawService, IFramework framework) : IPenumbraApiRedraw, IApiService public class RedrawApi(RedrawService redrawService, IFramework framework, CollectionManager collections, ObjectManager objects, ApiHelpers helpers) : IPenumbraApiRedraw, IApiService
{ {
public void RedrawObject(int gameObjectIndex, RedrawType setting) public void RedrawObject(int gameObjectIndex, RedrawType setting)
{ {
@ -28,9 +31,27 @@ public class RedrawApi(RedrawService redrawService, IFramework framework) : IPen
framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting)); framework.RunOnFrameworkThread(() => redrawService.RedrawAll(setting));
} }
public void RedrawCollectionMembers(Guid collectionId, RedrawType setting)
{
if (!collections.Storage.ById(collectionId, out var collection))
collection = ModCollection.Empty;
framework.RunOnFrameworkThread(() =>
{
foreach (var actor in objects.Objects)
{
helpers.AssociatedCollection(actor.ObjectIndex, out var modCollection);
if (collection == modCollection)
{
redrawService.RedrawObject(actor.ObjectIndex, setting);
}
}
});
}
public event GameObjectRedrawnDelegate? GameObjectRedrawn public event GameObjectRedrawnDelegate? GameObjectRedrawn
{ {
add => redrawService.GameObjectRedrawn += value; add => redrawService.GameObjectRedrawn += value;
remove => redrawService.GameObjectRedrawn -= value; remove => redrawService.GameObjectRedrawn -= value;
} }
} }

View file

@ -20,8 +20,16 @@ public class TemporaryApi(
ApiHelpers apiHelpers, ApiHelpers apiHelpers,
ModManager modManager) : IPenumbraApiTemporary, IApiService ModManager modManager) : IPenumbraApiTemporary, IApiService
{ {
public Guid CreateTemporaryCollection(string name) public (PenumbraApiEc, Guid) CreateTemporaryCollection(string identity, string name)
=> tempCollections.CreateTemporaryCollection(name); {
if (!IdentityChecker.Check(identity))
return (PenumbraApiEc.InvalidCredentials, Guid.Empty);
var collection = tempCollections.CreateTemporaryCollection(name);
if (collection == Guid.Empty)
return (PenumbraApiEc.UnknownError, collection);
return (PenumbraApiEc.Success, collection);
}
public PenumbraApiEc DeleteTemporaryCollection(Guid collectionId) public PenumbraApiEc DeleteTemporaryCollection(Guid collectionId)
=> tempCollections.RemoveTemporaryCollection(collectionId) => tempCollections.RemoveTemporaryCollection(collectionId)

View file

@ -5,6 +5,7 @@ using EmbedIO.WebApi;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Api; using Penumbra.Api.Api;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Mods.Settings;
namespace Penumbra.Api; namespace Penumbra.Api;
@ -13,12 +14,15 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller : WebApiController private partial class Controller : WebApiController
{ {
// @formatter:off // @formatter:off
[Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods(); [Route( HttpVerbs.Get, "/moddirectory" )] public partial string GetModDirectory();
[Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw(); [Route( HttpVerbs.Get, "/mods" )] public partial object? GetMods();
[Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll(); [Route( HttpVerbs.Post, "/redraw" )] public partial Task Redraw();
[Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod(); [Route( HttpVerbs.Post, "/redrawAll" )] public partial Task RedrawAll();
[Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod(); [Route( HttpVerbs.Post, "/reloadmod" )] public partial Task ReloadMod();
[Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow(); [Route( HttpVerbs.Post, "/installmod" )] public partial Task InstallMod();
[Route( HttpVerbs.Post, "/openwindow" )] public partial void OpenWindow();
[Route( HttpVerbs.Post, "/focusmod" )] public partial Task FocusMod();
[Route( HttpVerbs.Post, "/setmodsettings")] public partial Task SetModSettings();
// @formatter:on // @formatter:on
} }
@ -64,6 +68,12 @@ public class HttpApi : IDisposable, IApiService
private partial class Controller(IPenumbraApi api, IFramework framework) private partial class Controller(IPenumbraApi api, IFramework framework)
{ {
public partial string GetModDirectory()
{
Penumbra.Log.Debug($"[HTTP] {nameof(GetModDirectory)} triggered.");
return api.PluginState.GetModDirectory();
}
public partial object? GetMods() public partial object? GetMods()
{ {
Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered."); Penumbra.Log.Debug($"[HTTP] {nameof(GetMods)} triggered.");
@ -116,6 +126,38 @@ public class HttpApi : IDisposable, IApiService
api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty); api.Ui.OpenMainWindow(TabType.Mods, string.Empty, string.Empty);
} }
public async partial Task FocusMod()
{
var data = await HttpContext.GetRequestDataAsync<ModFocusData>().ConfigureAwait(false);
Penumbra.Log.Debug($"[HTTP] {nameof(FocusMod)} triggered.");
if (data.Path.Length != 0)
api.Ui.OpenMainWindow(TabType.Mods, data.Path, data.Name);
}
public async partial Task SetModSettings()
{
var data = await HttpContext.GetRequestDataAsync<SetModSettingsData>().ConfigureAwait(false);
Penumbra.Log.Debug($"[HTTP] {nameof(SetModSettings)} triggered.");
await framework.RunOnFrameworkThread(() =>
{
var collection = data.CollectionId ?? api.Collection.GetCollection(ApiCollectionType.Current)!.Value.Id;
if (data.Inherit.HasValue)
{
api.ModSettings.TryInheritMod(collection, data.ModPath, data.ModName, data.Inherit.Value);
if (data.Inherit.Value)
return;
}
if (data.State.HasValue)
api.ModSettings.TrySetMod(collection, data.ModPath, data.ModName, data.State.Value);
if (data.Priority.HasValue)
api.ModSettings.TrySetModPriority(collection, data.ModPath, data.ModName, data.Priority.Value);
foreach (var (group, settings) in data.Settings ?? [])
api.ModSettings.TrySetModSettings(collection, data.ModPath, data.ModName, group, settings);
}
).ConfigureAwait(false);
}
private record ModReloadData(string Path, string Name) private record ModReloadData(string Path, string Name)
{ {
public ModReloadData() public ModReloadData()
@ -123,6 +165,13 @@ public class HttpApi : IDisposable, IApiService
{ } { }
} }
private record ModFocusData(string Path, string Name)
{
public ModFocusData()
: this(string.Empty, string.Empty)
{ }
}
private record ModInstallData(string Path) private record ModInstallData(string Path)
{ {
public ModInstallData() public ModInstallData()
@ -136,5 +185,19 @@ public class HttpApi : IDisposable, IApiService
: this(string.Empty, RedrawType.Redraw, -1) : this(string.Empty, RedrawType.Redraw, -1)
{ } { }
} }
private record SetModSettingsData(
Guid? CollectionId,
string ModPath,
string ModName,
bool? Inherit,
bool? State,
int? Priority,
Dictionary<string, List<string>>? Settings)
{
public SetModSettingsData()
: this(null, string.Empty, string.Empty, null, null, null, null)
{}
}
} }
} }

View file

@ -54,6 +54,8 @@ public sealed class IpcProviders : IDisposable, IApiService
IpcSubscribers.ModDeleted.Provider(pi, api.Mods), IpcSubscribers.ModDeleted.Provider(pi, api.Mods),
IpcSubscribers.ModAdded.Provider(pi, api.Mods), IpcSubscribers.ModAdded.Provider(pi, api.Mods),
IpcSubscribers.ModMoved.Provider(pi, api.Mods), IpcSubscribers.ModMoved.Provider(pi, api.Mods),
IpcSubscribers.CreatingPcp.Provider(pi, api.Mods),
IpcSubscribers.ParsingPcp.Provider(pi, api.Mods),
IpcSubscribers.GetModPath.Provider(pi, api.Mods), IpcSubscribers.GetModPath.Provider(pi, api.Mods),
IpcSubscribers.SetModPath.Provider(pi, api.Mods), IpcSubscribers.SetModPath.Provider(pi, api.Mods),
IpcSubscribers.GetChangedItems.Provider(pi, api.Mods), IpcSubscribers.GetChangedItems.Provider(pi, api.Mods),
@ -80,10 +82,13 @@ public sealed class IpcProviders : IDisposable, IApiService
IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState), IpcSubscribers.ModDirectoryChanged.Provider(pi, api.PluginState),
IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState), IpcSubscribers.GetEnabledState.Provider(pi, api.PluginState),
IpcSubscribers.EnabledChange.Provider(pi, api.PluginState), IpcSubscribers.EnabledChange.Provider(pi, api.PluginState),
IpcSubscribers.SupportedFeatures.Provider(pi, api.PluginState),
IpcSubscribers.CheckSupportedFeatures.Provider(pi, api.PluginState),
IpcSubscribers.RedrawObject.Provider(pi, api.Redraw), IpcSubscribers.RedrawObject.Provider(pi, api.Redraw),
IpcSubscribers.RedrawAll.Provider(pi, api.Redraw), IpcSubscribers.RedrawAll.Provider(pi, api.Redraw),
IpcSubscribers.GameObjectRedrawn.Provider(pi, api.Redraw), IpcSubscribers.GameObjectRedrawn.Provider(pi, api.Redraw),
IpcSubscribers.RedrawCollectionMembers.Provider(pi, api.Redraw),
IpcSubscribers.ResolveDefaultPath.Provider(pi, api.Resolve), IpcSubscribers.ResolveDefaultPath.Provider(pi, api.Resolve),
IpcSubscribers.ResolveInterfacePath.Provider(pi, api.Resolve), IpcSubscribers.ResolveInterfacePath.Provider(pi, api.Resolve),

View file

@ -1,7 +1,7 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
@ -121,6 +121,10 @@ public class CollectionsIpcTester(IDalamudPluginInterface pi) : IUiService
}).ToArray(); }).ToArray();
ImGui.OpenPopup("Changed Item List"); ImGui.OpenPopup("Changed Item List");
} }
IpcTester.DrawIntro(RedrawCollectionMembers.Label, "Redraw Collection Members");
if (ImGui.Button("Redraw##ObjectCollection"))
new RedrawCollectionMembers(pi).Invoke(collectionList[0].Id, RedrawType.Redraw);
} }
private void DrawChangedItemPopup() private void DrawChangedItemPopup()

View file

@ -1,5 +1,5 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;

View file

@ -1,6 +1,6 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;

View file

@ -1,6 +1,6 @@
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.System.Framework;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Api; using Penumbra.Api.Api;

View file

@ -1,5 +1,5 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using OtterGui.Text; using OtterGui.Text;

View file

@ -1,5 +1,5 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;

View file

@ -1,6 +1,6 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using OtterGui.Text; using OtterGui.Text;

View file

@ -1,10 +1,11 @@
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using OtterGui.Text;
using Penumbra.Api.Helpers; using Penumbra.Api.Helpers;
using Penumbra.Api.IpcSubscribers; using Penumbra.Api.IpcSubscribers;
@ -12,7 +13,7 @@ namespace Penumbra.Api.IpcTester;
public class PluginStateIpcTester : IUiService, IDisposable public class PluginStateIpcTester : IUiService, IDisposable
{ {
private readonly IDalamudPluginInterface _pi; private readonly IDalamudPluginInterface _pi;
public readonly EventSubscriber<string, bool> ModDirectoryChanged; public readonly EventSubscriber<string, bool> ModDirectoryChanged;
public readonly EventSubscriber Initialized; public readonly EventSubscriber Initialized;
public readonly EventSubscriber Disposed; public readonly EventSubscriber Disposed;
@ -26,6 +27,9 @@ public class PluginStateIpcTester : IUiService, IDisposable
private readonly List<DateTimeOffset> _initializedList = []; private readonly List<DateTimeOffset> _initializedList = [];
private readonly List<DateTimeOffset> _disposedList = []; private readonly List<DateTimeOffset> _disposedList = [];
private string _requiredFeatureString = string.Empty;
private string[] _requiredFeatures = [];
private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch; private DateTimeOffset _lastEnabledChange = DateTimeOffset.UnixEpoch;
private bool? _lastEnabledValue; private bool? _lastEnabledValue;
@ -48,12 +52,15 @@ public class PluginStateIpcTester : IUiService, IDisposable
EnabledChange.Dispose(); EnabledChange.Dispose();
} }
public void Draw() public void Draw()
{ {
using var _ = ImRaii.TreeNode("Plugin State"); using var _ = ImRaii.TreeNode("Plugin State");
if (!_) if (!_)
return; return;
if (ImUtf8.InputText("Required Features"u8, ref _requiredFeatureString))
_requiredFeatures = _requiredFeatureString.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit); using var table = ImRaii.Table(string.Empty, 3, ImGuiTableFlags.SizingFixedFit);
if (!table) if (!table)
return; return;
@ -71,6 +78,12 @@ public class PluginStateIpcTester : IUiService, IDisposable
IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change"); IpcTester.DrawIntro(IpcSubscribers.EnabledChange.Label, "Last Change");
ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never"); ImGui.TextUnformatted(_lastEnabledValue is { } v ? $"{_lastEnabledChange} (to {v})" : "Never");
IpcTester.DrawIntro(SupportedFeatures.Label, "Supported Features");
ImUtf8.Text(string.Join(", ", new SupportedFeatures(_pi).Invoke()));
IpcTester.DrawIntro(CheckSupportedFeatures.Label, "Missing Features");
ImUtf8.Text(string.Join(", ", new CheckSupportedFeatures(_pi).Invoke(_requiredFeatures)));
DrawConfigPopup(); DrawConfigPopup();
IpcTester.DrawIntro(GetConfiguration.Label, "Configuration"); IpcTester.DrawIntro(GetConfiguration.Label, "Configuration");
if (ImGui.Button("Get")) if (ImGui.Button("Get"))

View file

@ -1,6 +1,6 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;

View file

@ -1,5 +1,5 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.IpcSubscribers; using Penumbra.Api.IpcSubscribers;

View file

@ -1,8 +1,8 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.Enums; using Dalamud.Game.ClientState.Objects.Enums;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Utility; using Dalamud.Interface.Utility;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET;
using OtterGui; using OtterGui;
using OtterGui.Extensions; using OtterGui.Extensions;
using OtterGui.Raii; using OtterGui.Raii;

View file

@ -1,6 +1,6 @@
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui; using OtterGui;
using OtterGui.Extensions; using OtterGui.Extensions;
using OtterGui.Raii; using OtterGui.Raii;
@ -38,6 +38,7 @@ public class TemporaryIpcTester(
private string _tempGamePath = "test/game/path.mtrl"; private string _tempGamePath = "test/game/path.mtrl";
private string _tempFilePath = "test/success.mtrl"; private string _tempFilePath = "test/success.mtrl";
private string _tempManipulation = string.Empty; private string _tempManipulation = string.Empty;
private string _identity = string.Empty;
private PenumbraApiEc _lastTempError; private PenumbraApiEc _lastTempError;
private int _tempActorIndex; private int _tempActorIndex;
private bool _forceOverwrite; private bool _forceOverwrite;
@ -48,6 +49,7 @@ public class TemporaryIpcTester(
if (!_) if (!_)
return; return;
ImGui.InputTextWithHint("##identity", "Identity...", ref _identity, 128);
ImGui.InputTextWithHint("##tempCollection", "Collection Name...", ref _tempCollectionName, 128); ImGui.InputTextWithHint("##tempCollection", "Collection Name...", ref _tempCollectionName, 128);
ImGuiUtil.GuidInput("##guid", "Collection GUID...", string.Empty, ref _tempGuid, ref _tempCollectionGuidName); ImGuiUtil.GuidInput("##guid", "Collection GUID...", string.Empty, ref _tempGuid, ref _tempCollectionGuidName);
ImGui.InputInt("##tempActorIndex", ref _tempActorIndex, 0, 0); ImGui.InputInt("##tempActorIndex", ref _tempActorIndex, 0, 0);
@ -73,7 +75,7 @@ public class TemporaryIpcTester(
IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Create Temporary Collection"); IpcTester.DrawIntro(CreateTemporaryCollection.Label, "Create Temporary Collection");
if (ImGui.Button("Create##Collection")) if (ImGui.Button("Create##Collection"))
{ {
LastCreatedCollectionId = new CreateTemporaryCollection(pi).Invoke(_tempCollectionName); _lastTempError = new CreateTemporaryCollection(pi).Invoke(_identity, _tempCollectionName, out LastCreatedCollectionId);
if (_tempGuid == null) if (_tempGuid == null)
{ {
_tempGuid = LastCreatedCollectionId; _tempGuid = LastCreatedCollectionId;
@ -282,7 +284,7 @@ public class TemporaryIpcTester(
foreach (var mod in list) foreach (var mod in list)
{ {
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(mod.Name); ImGui.TextUnformatted(mod.Name.Text);
ImGui.TableNextColumn(); ImGui.TableNextColumn();
ImGui.TextUnformatted(mod.Priority.ToString()); ImGui.TextUnformatted(mod.Priority.ToString());
ImGui.TableNextColumn(); ImGui.TableNextColumn();

View file

@ -1,5 +1,5 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;

View file

@ -1,4 +1,4 @@
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Text; using OtterGui.Text;
namespace Penumbra; namespace Penumbra;

View file

@ -120,6 +120,7 @@ public sealed class ShapeAttributeHashSet : Dictionary<(HumanSlot Slot, PrimaryI
if (TryGetValue((slot, id.Value), out var flags)) if (TryGetValue((slot, id.Value), out var flags))
{ {
index *= 2;
var newFlags = value switch var newFlags = value switch
{ {
true => (flags | (1ul << index)) & ~(1ul << (index + 1)), true => (flags | (1ul << index)) & ~(1ul << (index + 1)),
@ -137,7 +138,7 @@ public sealed class ShapeAttributeHashSet : Dictionary<(HumanSlot Slot, PrimaryI
if (value is null) if (value is null)
return false; return false;
this[(slot, id.Value)] = 1ul << (index + (value.Value ? 0 : 1)); this[(slot, id.Value)] = 1ul << (2 * index + (value.Value ? 0 : 1));
return true; return true;
} }

View file

@ -59,8 +59,15 @@ public sealed class CollectionAutoSelector : IService, IDisposable
return; return;
var collection = _resolver.PlayerCollection(); var collection = _resolver.PlayerCollection();
Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection."); if (collection.Identity.Id == Guid.Empty)
_collections.SetCollection(collection, CollectionType.Current); {
Penumbra.Log.Debug($"Not setting current collection because character has no mods assigned.");
}
else
{
Penumbra.Log.Debug($"Setting current collection to {collection.Identity.Identifier} through automatic collection selection.");
_collections.SetCollection(collection, CollectionType.Current);
}
} }

View file

@ -1,7 +1,7 @@
using Dalamud.Game.Command; using Dalamud.Game.Command;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Api; using Penumbra.Api.Api;

View file

@ -3,6 +3,7 @@ using Penumbra.Api;
using Penumbra.Api.Api; using Penumbra.Api.Api;
using Penumbra.Mods; using Penumbra.Mods;
using Penumbra.Mods.Manager; using Penumbra.Mods.Manager;
using Penumbra.Services;
namespace Penumbra.Communication; namespace Penumbra.Communication;
@ -20,11 +21,14 @@ public sealed class ModPathChanged()
{ {
public enum Priority public enum Priority
{ {
/// <seealso cref="PcpService.OnModPathChange"/>
PcpService = int.MinValue,
/// <seealso cref="ModsApi.OnModPathChange"/> /// <seealso cref="ModsApi.OnModPathChange"/>
ApiMods = int.MinValue, ApiMods = int.MinValue + 1,
/// <seealso cref="ModSettingsApi.OnModPathChange"/> /// <seealso cref="ModSettingsApi.OnModPathChange"/>
ApiModSettings = int.MinValue, ApiModSettings = int.MinValue + 1,
/// <seealso cref="EphemeralConfig.OnModPathChanged"/> /// <seealso cref="EphemeralConfig.OnModPathChanged"/>
EphemeralConfig = -500, EphemeralConfig = -500,

View file

@ -0,0 +1,21 @@
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
namespace Penumbra.Communication;
/// <summary>
/// Triggered when the character.json file for a .pcp file is written.
/// <list type="number">
/// <item>Parameter is the JObject that gets written to file. </item>
/// <item>Parameter is the object index of the game object this is written for. </item>
/// <item>Parameter is the full path to the directory being set up for the PCP creation. </item>
/// </list>
/// </summary>
public sealed class PcpCreation() : EventWrapper<JObject, ushort, string, PcpCreation.Priority>(nameof(PcpCreation))
{
public enum Priority
{
/// <seealso cref="Api.Api.ModsApi"/>
ModsApi = int.MinValue,
}
}

View file

@ -0,0 +1,21 @@
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
namespace Penumbra.Communication;
/// <summary>
/// Triggered when the character.json file for a .pcp file is parsed and applied.
/// <list type="number">
/// <item>Parameter is parsed JObject that contains the data. </item>
/// <item>Parameter is the identifier of the created mod. </item>
/// <item>Parameter is the GUID of the created collection. </item>
/// </list>
/// </summary>
public sealed class PcpParsing() : EventWrapper<JObject, string, Guid, PcpParsing.Priority>(nameof(PcpParsing))
{
public enum Priority
{
/// <seealso cref="Api.Api.ModsApi"/>
ModsApi = int.MinValue,
}
}

View file

@ -18,6 +18,15 @@ using ErrorEventArgs = Newtonsoft.Json.Serialization.ErrorEventArgs;
namespace Penumbra; namespace Penumbra;
public record PcpSettings
{
public bool CreateCollection { get; set; } = true;
public bool AssignCollection { get; set; } = true;
public bool AllowIpc { get; set; } = true;
public bool DisableHandling { get; set; } = false;
public string FolderName { get; set; } = "PCP";
}
[Serializable] [Serializable]
public class Configuration : IPluginConfiguration, ISavable, IService public class Configuration : IPluginConfiguration, ISavable, IService
{ {
@ -44,6 +53,7 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public string ModDirectory { get; set; } = string.Empty; public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { get; set; } = string.Empty; public string ExportDirectory { get; set; } = string.Empty;
public string WatchDirectory { get; set; } = string.Empty;
public bool? UseCrashHandler { get; set; } = null; public bool? UseCrashHandler { get; set; } = null;
public bool OpenWindowAtStart { get; set; } = false; public bool OpenWindowAtStart { get; set; } = false;
@ -67,10 +77,13 @@ public class Configuration : IPluginConfiguration, ISavable, IService
public bool HideRedrawBar { get; set; } = false; public bool HideRedrawBar { get; set; } = false;
public bool HideMachinistOffhandFromChangedItems { get; set; } = true; public bool HideMachinistOffhandFromChangedItems { get; set; } = true;
public bool DefaultTemporaryMode { get; set; } = false; public bool DefaultTemporaryMode { get; set; } = false;
public bool EnableDirectoryWatch { get; set; } = false;
public bool EnableAutomaticModImport { get; set; } = false;
public bool EnableCustomShapes { get; set; } = true; public bool EnableCustomShapes { get; set; } = true;
public RenameField ShowRename { get; set; } = RenameField.BothDataPrio; public PcpSettings PcpSettings = new();
public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed; public RenameField ShowRename { get; set; } = RenameField.BothDataPrio;
public int OptionGroupCollapsibleMin { get; set; } = 5; public ChangedItemMode ChangedItemDisplay { get; set; } = ChangedItemMode.GroupedCollapsed;
public int OptionGroupCollapsibleMin { get; set; } = 5;
public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY); public Vector2 MinimumSize = new(Constants.MinimumSizeX, Constants.MinimumSizeY);

View file

@ -2,5 +2,6 @@ namespace Penumbra;
public class DebugConfiguration public class DebugConfiguration
{ {
public static bool WriteImcBytesToLog = false; public static bool WriteImcBytesToLog = false;
public static bool UseSkinMaterialProcessing = true;
} }

View file

@ -1,6 +1,7 @@
using Lumina.Data.Parsing; using Lumina.Data.Parsing;
using Penumbra.GameData.Files; using Penumbra.GameData.Files;
using Penumbra.GameData.Files.MaterialStructs; using Penumbra.GameData.Files.MaterialStructs;
using Penumbra.UI.AdvancedWindow.Materials;
using SharpGLTF.Materials; using SharpGLTF.Materials;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Advanced; using SixLabors.ImageSharp.Advanced;
@ -140,13 +141,13 @@ public class MaterialExporter
// Lerp between table row values to fetch final pixel values for each subtexture. // Lerp between table row values to fetch final pixel values for each subtexture.
var lerpedDiffuse = Vector3.Lerp((Vector3)prevRow.DiffuseColor, (Vector3)nextRow.DiffuseColor, rowBlend); var lerpedDiffuse = Vector3.Lerp((Vector3)prevRow.DiffuseColor, (Vector3)nextRow.DiffuseColor, rowBlend);
baseColorSpan[x].FromVector4(new Vector4(lerpedDiffuse, 1)); baseColorSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedDiffuse), 1));
var lerpedSpecularColor = Vector3.Lerp((Vector3)prevRow.SpecularColor, (Vector3)nextRow.SpecularColor, rowBlend); var lerpedSpecularColor = Vector3.Lerp((Vector3)prevRow.SpecularColor, (Vector3)nextRow.SpecularColor, rowBlend);
specularSpan[x].FromVector4(new Vector4(lerpedSpecularColor, 1)); specularSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedSpecularColor), 1));
var lerpedEmissive = Vector3.Lerp((Vector3)prevRow.EmissiveColor, (Vector3)nextRow.EmissiveColor, rowBlend); var lerpedEmissive = Vector3.Lerp((Vector3)prevRow.EmissiveColor, (Vector3)nextRow.EmissiveColor, rowBlend);
emissiveSpan[x].FromVector4(new Vector4(lerpedEmissive, 1)); emissiveSpan[x].FromVector4(new Vector4(MtrlTab.PseudoSqrtRgb(lerpedEmissive), 1));
} }
} }
} }

View file

@ -360,11 +360,11 @@ public class MeshExporter
// (Bi)tangents are universally stored as ByteFloat4, which uses 0..1 to represent the full -1..1 range. // (Bi)tangents are universally stored as ByteFloat4, which uses 0..1 to represent the full -1..1 range.
// TODO: While this assumption is safe, it would be sensible to actually check. // TODO: While this assumption is safe, it would be sensible to actually check.
var bitangent = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Tangent1)) * 2 - Vector4.One; var bitangent = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Tangent1)) * 2 - Vector4.One;
return new VertexPositionNormalTangent( return new VertexPositionNormalTangent(
ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)), ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Position)),
ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)), ToVector3(GetFirstSafe(attributes, MdlFile.VertexUsage.Normal)),
bitangent bitangent.SanitizeTangent()
); );
} }
@ -390,23 +390,30 @@ public class MeshExporter
} }
} }
usages.TryGetValue(MdlFile.VertexUsage.Color, out var colours);
var nColors = colours?.Count ?? 0;
var materialUsages = ( var materialUsages = (
uvCount, uvCount,
usages.ContainsKey(MdlFile.VertexUsage.Color) nColors
); );
return materialUsages switch return materialUsages switch
{ {
(3, true) => typeof(VertexTexture3ColorFfxiv), (3, 2) => typeof(VertexTexture3Color2Ffxiv),
(3, false) => typeof(VertexTexture3), (3, 1) => typeof(VertexTexture3ColorFfxiv),
(2, true) => typeof(VertexTexture2ColorFfxiv), (3, 0) => typeof(VertexTexture3),
(2, false) => typeof(VertexTexture2), (2, 2) => typeof(VertexTexture2Color2Ffxiv),
(1, true) => typeof(VertexTexture1ColorFfxiv), (2, 1) => typeof(VertexTexture2ColorFfxiv),
(1, false) => typeof(VertexTexture1), (2, 0) => typeof(VertexTexture2),
(0, true) => typeof(VertexColorFfxiv), (1, 2) => typeof(VertexTexture1Color2Ffxiv),
(0, false) => typeof(VertexEmpty), (1, 1) => typeof(VertexTexture1ColorFfxiv),
(1, 0) => typeof(VertexTexture1),
(0, 2) => typeof(VertexColor2Ffxiv),
(0, 1) => typeof(VertexColorFfxiv),
(0, 0) => typeof(VertexEmpty),
_ => throw _notifier.Exception($"Unhandled UV count of {uvCount} encountered."), _ => throw _notifier.Exception($"Unhandled UV/color count of {uvCount}/{nColors} encountered."),
}; };
} }
@ -419,6 +426,12 @@ public class MeshExporter
if (_materialType == typeof(VertexColorFfxiv)) if (_materialType == typeof(VertexColorFfxiv))
return new VertexColorFfxiv(ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color))); return new VertexColorFfxiv(ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)));
if (_materialType == typeof(VertexColor2Ffxiv))
{
var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color);
return new VertexColor2Ffxiv(ToVector4(color0), ToVector4(color1));
}
if (_materialType == typeof(VertexTexture1)) if (_materialType == typeof(VertexTexture1))
return new VertexTexture1(ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV))); return new VertexTexture1(ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)));
@ -428,6 +441,16 @@ public class MeshExporter
ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color))
); );
if (_materialType == typeof(VertexTexture1Color2Ffxiv))
{
var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color);
return new VertexTexture1Color2Ffxiv(
ToVector2(GetFirstSafe(attributes, MdlFile.VertexUsage.UV)),
ToVector4(color0),
ToVector4(color1)
);
}
// XIV packs two UVs into a single vec4 attribute. // XIV packs two UVs into a single vec4 attribute.
if (_materialType == typeof(VertexTexture2)) if (_materialType == typeof(VertexTexture2))
@ -448,6 +471,20 @@ public class MeshExporter
ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color)) ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.Color))
); );
} }
if (_materialType == typeof(VertexTexture2Color2Ffxiv))
{
var uv = ToVector4(GetFirstSafe(attributes, MdlFile.VertexUsage.UV));
var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color);
return new VertexTexture2Color2Ffxiv(
new Vector2(uv.X, uv.Y),
new Vector2(uv.Z, uv.W),
ToVector4(color0),
ToVector4(color1)
);
}
if (_materialType == typeof(VertexTexture3)) if (_materialType == typeof(VertexTexture3))
{ {
// Not 100% sure about this // Not 100% sure about this
@ -472,6 +509,21 @@ public class MeshExporter
); );
} }
if (_materialType == typeof(VertexTexture3Color2Ffxiv))
{
var uv0 = ToVector4(attributes[MdlFile.VertexUsage.UV][0]);
var uv1 = ToVector4(attributes[MdlFile.VertexUsage.UV][1]);
var (color0, color1) = GetBothSafe(attributes, MdlFile.VertexUsage.Color);
return new VertexTexture3Color2Ffxiv(
new Vector2(uv0.X, uv0.Y),
new Vector2(uv0.Z, uv0.W),
new Vector2(uv1.X, uv1.Y),
ToVector4(color0),
ToVector4(color1)
);
}
throw _notifier.Exception($"Unknown material type {_skinningType}"); throw _notifier.Exception($"Unknown material type {_skinningType}");
} }
@ -537,6 +589,17 @@ public class MeshExporter
return list[0]; return list[0];
} }
/// <summary> Check that the list has length 2 for any case where this is expected and return both entries. </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private (T First, T Second) GetBothSafe<T>(IReadOnlyDictionary<MdlFile.VertexUsage, List<T>> attributes, MdlFile.VertexUsage usage)
{
var list = attributes[usage];
if (list.Count != 2)
throw _notifier.Exception($"{list.Count} usage indices encountered for {usage}, but expected 2.");
return (list[0], list[1]);
}
/// <summary> Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. </summary> /// <summary> Convert a vertex attribute value to a Vector2. Supported inputs are Vector2, Vector3, and Vector4. </summary>
private static Vector2 ToVector2(object data) private static Vector2 ToVector2(object data)

View file

@ -84,6 +84,103 @@ public struct VertexColorFfxiv(Vector4 ffxivColor) : IVertexCustom
} }
} }
public struct VertexColor2Ffxiv(Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom
{
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
{
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_0",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_1",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
}
public Vector4 FfxivColor0 = ffxivColor0;
public Vector4 FfxivColor1 = ffxivColor1;
public int MaxColors
=> 0;
public int MaxTextCoords
=> 0;
private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"];
public IEnumerable<string> CustomAttributes
=> CustomNames;
public void Add(in VertexMaterialDelta delta)
{ }
public VertexMaterialDelta Subtract(IVertexMaterial baseValue)
=> new(Vector4.Zero, Vector4.Zero, Vector2.Zero, Vector2.Zero);
public Vector2 GetTexCoord(int index)
=> throw new ArgumentOutOfRangeException(nameof(index));
public void SetTexCoord(int setIndex, Vector2 coord)
{ }
public bool TryGetCustomAttribute(string attributeName, out object? value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0":
value = FfxivColor0;
return true;
case "_FFXIV_COLOR_1":
value = FfxivColor1;
return true;
default:
value = null;
return false;
}
}
public void SetCustomAttribute(string attributeName, object value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0" when value is Vector4 valueVector4:
FfxivColor0 = valueVector4;
break;
case "_FFXIV_COLOR_1" when value is Vector4 valueVector4:
FfxivColor1 = valueVector4;
break;
}
}
public Vector4 GetColor(int index)
=> throw new ArgumentOutOfRangeException(nameof(index));
public void SetColor(int setIndex, Vector4 color)
{ }
public void Validate()
{
var components = new[]
{
FfxivColor0.X,
FfxivColor0.Y,
FfxivColor0.Z,
FfxivColor0.W,
};
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor0));
components =
[
FfxivColor1.X,
FfxivColor1.Y,
FfxivColor1.Z,
FfxivColor1.W,
];
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor1));
}
}
public struct VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) : IVertexCustom public struct VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) : IVertexCustom
{ {
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes() public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
@ -172,6 +269,118 @@ public struct VertexTexture1ColorFfxiv(Vector2 texCoord0, Vector4 ffxivColor) :
} }
} }
public struct VertexTexture1Color2Ffxiv(Vector2 texCoord0, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom
{
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
{
yield return new KeyValuePair<string, AttributeFormat>("TEXCOORD_0",
new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_0",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_1",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
}
public Vector2 TexCoord0 = texCoord0;
public Vector4 FfxivColor0 = ffxivColor0;
public Vector4 FfxivColor1 = ffxivColor1;
public int MaxColors
=> 0;
public int MaxTextCoords
=> 1;
private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"];
public IEnumerable<string> CustomAttributes
=> CustomNames;
public void Add(in VertexMaterialDelta delta)
{
TexCoord0 += delta.TexCoord0Delta;
}
public VertexMaterialDelta Subtract(IVertexMaterial baseValue)
=> new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), Vector2.Zero);
public Vector2 GetTexCoord(int index)
=> index switch
{
0 => TexCoord0,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
public void SetTexCoord(int setIndex, Vector2 coord)
{
if (setIndex == 0)
TexCoord0 = coord;
if (setIndex >= 1)
throw new ArgumentOutOfRangeException(nameof(setIndex));
}
public bool TryGetCustomAttribute(string attributeName, out object? value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0":
value = FfxivColor0;
return true;
case "_FFXIV_COLOR_1":
value = FfxivColor1;
return true;
default:
value = null;
return false;
}
}
public void SetCustomAttribute(string attributeName, object value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0" when value is Vector4 valueVector4:
FfxivColor0 = valueVector4;
break;
case "_FFXIV_COLOR_1" when value is Vector4 valueVector4:
FfxivColor1 = valueVector4;
break;
}
}
public Vector4 GetColor(int index)
=> throw new ArgumentOutOfRangeException(nameof(index));
public void SetColor(int setIndex, Vector4 color)
{ }
public void Validate()
{
var components = new[]
{
FfxivColor0.X,
FfxivColor0.Y,
FfxivColor0.Z,
FfxivColor0.W,
};
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor0));
components =
[
FfxivColor1.X,
FfxivColor1.Y,
FfxivColor1.Z,
FfxivColor1.W,
];
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor1));
}
}
public struct VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor) : IVertexCustom public struct VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor) : IVertexCustom
{ {
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes() public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
@ -266,6 +475,124 @@ public struct VertexTexture2ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vec
} }
} }
public struct VertexTexture2Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector4 ffxivColor0, Vector4 ffxivColor1) : IVertexCustom
{
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
{
yield return new KeyValuePair<string, AttributeFormat>("TEXCOORD_0",
new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false));
yield return new KeyValuePair<string, AttributeFormat>("TEXCOORD_1",
new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_0",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_1",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
}
public Vector2 TexCoord0 = texCoord0;
public Vector2 TexCoord1 = texCoord1;
public Vector4 FfxivColor0 = ffxivColor0;
public Vector4 FfxivColor1 = ffxivColor1;
public int MaxColors
=> 0;
public int MaxTextCoords
=> 2;
private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"];
public IEnumerable<string> CustomAttributes
=> CustomNames;
public void Add(in VertexMaterialDelta delta)
{
TexCoord0 += delta.TexCoord0Delta;
TexCoord1 += delta.TexCoord1Delta;
}
public VertexMaterialDelta Subtract(IVertexMaterial baseValue)
=> new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1));
public Vector2 GetTexCoord(int index)
=> index switch
{
0 => TexCoord0,
1 => TexCoord1,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
public void SetTexCoord(int setIndex, Vector2 coord)
{
if (setIndex == 0)
TexCoord0 = coord;
if (setIndex == 1)
TexCoord1 = coord;
if (setIndex >= 2)
throw new ArgumentOutOfRangeException(nameof(setIndex));
}
public bool TryGetCustomAttribute(string attributeName, out object? value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0":
value = FfxivColor0;
return true;
case "_FFXIV_COLOR_1":
value = FfxivColor1;
return true;
default:
value = null;
return false;
}
}
public void SetCustomAttribute(string attributeName, object value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0" when value is Vector4 valueVector4:
FfxivColor0 = valueVector4;
break;
case "_FFXIV_COLOR_1" when value is Vector4 valueVector4:
FfxivColor1 = valueVector4;
break;
}
}
public Vector4 GetColor(int index)
=> throw new ArgumentOutOfRangeException(nameof(index));
public void SetColor(int setIndex, Vector4 color)
{ }
public void Validate()
{
var components = new[]
{
FfxivColor0.X,
FfxivColor0.Y,
FfxivColor0.Z,
FfxivColor0.W,
};
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor0));
components =
[
FfxivColor1.X,
FfxivColor1.Y,
FfxivColor1.Z,
FfxivColor1.W,
];
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor1));
}
}
public struct VertexTexture3ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor) public struct VertexTexture3ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor)
: IVertexCustom : IVertexCustom
{ {
@ -367,3 +694,126 @@ public struct VertexTexture3ColorFfxiv(Vector2 texCoord0, Vector2 texCoord1, Vec
throw new ArgumentOutOfRangeException(nameof(FfxivColor)); throw new ArgumentOutOfRangeException(nameof(FfxivColor));
} }
} }
public struct VertexTexture3Color2Ffxiv(Vector2 texCoord0, Vector2 texCoord1, Vector2 texCoord2, Vector4 ffxivColor0, Vector4 ffxivColor1)
: IVertexCustom
{
public IEnumerable<KeyValuePair<string, AttributeFormat>> GetEncodingAttributes()
{
yield return new KeyValuePair<string, AttributeFormat>("TEXCOORD_0",
new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false));
yield return new KeyValuePair<string, AttributeFormat>("TEXCOORD_1",
new AttributeFormat(DimensionType.VEC2, EncodingType.FLOAT, false));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_0",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
yield return new KeyValuePair<string, AttributeFormat>("_FFXIV_COLOR_1",
new AttributeFormat(DimensionType.VEC4, EncodingType.UNSIGNED_SHORT, true));
}
public Vector2 TexCoord0 = texCoord0;
public Vector2 TexCoord1 = texCoord1;
public Vector2 TexCoord2 = texCoord2;
public Vector4 FfxivColor0 = ffxivColor0;
public Vector4 FfxivColor1 = ffxivColor1;
public int MaxColors
=> 0;
public int MaxTextCoords
=> 3;
private static readonly string[] CustomNames = ["_FFXIV_COLOR_0", "_FFXIV_COLOR_1"];
public IEnumerable<string> CustomAttributes
=> CustomNames;
public void Add(in VertexMaterialDelta delta)
{
TexCoord0 += delta.TexCoord0Delta;
TexCoord1 += delta.TexCoord1Delta;
TexCoord2 += delta.TexCoord2Delta;
}
public VertexMaterialDelta Subtract(IVertexMaterial baseValue)
=> new(Vector4.Zero, Vector4.Zero, TexCoord0 - baseValue.GetTexCoord(0), TexCoord1 - baseValue.GetTexCoord(1));
public Vector2 GetTexCoord(int index)
=> index switch
{
0 => TexCoord0,
1 => TexCoord1,
2 => TexCoord2,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
public void SetTexCoord(int setIndex, Vector2 coord)
{
if (setIndex == 0)
TexCoord0 = coord;
if (setIndex == 1)
TexCoord1 = coord;
if (setIndex == 2)
TexCoord2 = coord;
if (setIndex >= 3)
throw new ArgumentOutOfRangeException(nameof(setIndex));
}
public bool TryGetCustomAttribute(string attributeName, out object? value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0":
value = FfxivColor0;
return true;
case "_FFXIV_COLOR_1":
value = FfxivColor1;
return true;
default:
value = null;
return false;
}
}
public void SetCustomAttribute(string attributeName, object value)
{
switch (attributeName)
{
case "_FFXIV_COLOR_0" when value is Vector4 valueVector4:
FfxivColor0 = valueVector4;
break;
case "_FFXIV_COLOR_1" when value is Vector4 valueVector4:
FfxivColor1 = valueVector4;
break;
}
}
public Vector4 GetColor(int index)
=> throw new ArgumentOutOfRangeException(nameof(index));
public void SetColor(int setIndex, Vector4 color)
{ }
public void Validate()
{
var components = new[]
{
FfxivColor0.X,
FfxivColor0.Y,
FfxivColor0.Z,
FfxivColor0.W,
};
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor0));
components =
[
FfxivColor1.X,
FfxivColor1.Y,
FfxivColor1.Z,
FfxivColor1.W,
];
if (components.Any(component => component is < 0 or > 1))
throw new ArgumentOutOfRangeException(nameof(FfxivColor1));
}
}

View file

@ -319,7 +319,7 @@ public class VertexAttribute
var normals = normalAccessor.AsVector3Array(); var normals = normalAccessor.AsVector3Array();
var tangents = accessors.TryGetValue("TANGENT", out var accessor) var tangents = accessors.TryGetValue("TANGENT", out var accessor)
? accessor.AsVector4Array() ? accessor.AsVector4Array().ToArray()
: CalculateTangents(accessors, indices, normals, notifier); : CalculateTangents(accessors, indices, normals, notifier);
if (tangents == null) if (tangents == null)

View file

@ -0,0 +1,69 @@
namespace Penumbra.Import.Models;
public static class ModelExtensions
{
// https://github.com/vpenades/SharpGLTF/blob/2073cf3cd671f8ecca9667f9a8c7f04ed865d3ac/src/Shared/_Extensions.cs#L158
private const float UnitLengthThresholdVec3 = 0.00674f;
private const float UnitLengthThresholdVec4 = 0.00769f;
internal static bool _IsFinite(this float value)
{
return float.IsFinite(value);
}
internal static bool _IsFinite(this Vector2 v)
{
return v.X._IsFinite() && v.Y._IsFinite();
}
internal static bool _IsFinite(this Vector3 v)
{
return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite();
}
internal static bool _IsFinite(this in Vector4 v)
{
return v.X._IsFinite() && v.Y._IsFinite() && v.Z._IsFinite() && v.W._IsFinite();
}
internal static Boolean IsNormalized(this Vector3 normal)
{
if (!normal._IsFinite()) return false;
return Math.Abs(normal.Length() - 1) <= UnitLengthThresholdVec3;
}
internal static void ValidateNormal(this Vector3 normal, string msg)
{
if (!normal._IsFinite()) throw new NotFiniteNumberException($"{msg} is invalid.");
if (!normal.IsNormalized()) throw new ArithmeticException($"{msg} is not unit length.");
}
internal static void ValidateTangent(this Vector4 tangent, string msg)
{
if (tangent.W != 1 && tangent.W != -1) throw new ArithmeticException(msg);
new Vector3(tangent.X, tangent.Y, tangent.Z).ValidateNormal(msg);
}
internal static Vector3 SanitizeNormal(this Vector3 normal)
{
if (normal == Vector3.Zero) return Vector3.UnitX;
return normal.IsNormalized() ? normal : Vector3.Normalize(normal);
}
internal static bool IsValidTangent(this Vector4 tangent)
{
if (tangent.W != 1 && tangent.W != -1) return false;
return new Vector3(tangent.X, tangent.Y, tangent.Z).IsNormalized();
}
internal static Vector4 SanitizeTangent(this Vector4 tangent)
{
var n = new Vector3(tangent.X, tangent.Y, tangent.Z).SanitizeNormal();
var s = float.IsNaN(tangent.W) ? 1 : tangent.W;
return new Vector4(n, s > 0 ? 1 : -1);
}
}

View file

@ -119,7 +119,7 @@ public partial class TexToolsImporter : IDisposable
// Puts out warnings if extension does not correspond to data. // Puts out warnings if extension does not correspond to data.
private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile) private DirectoryInfo VerifyVersionAndImport(FileInfo modPackFile)
{ {
if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".zip" or ".7z" or ".rar") if (modPackFile.Extension.ToLowerInvariant() is ".pmp" or ".pcp" or ".zip" or ".7z" or ".rar")
return HandleRegularArchive(modPackFile); return HandleRegularArchive(modPackFile);
using var zfs = modPackFile.OpenRead(); using var zfs = modPackFile.OpenRead();

View file

@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq;
using OtterGui.Filesystem; using OtterGui.Filesystem;
using Penumbra.Import.Structs; using Penumbra.Import.Structs;
using Penumbra.Mods; using Penumbra.Mods;
using Penumbra.Services;
using SharpCompress.Archives; using SharpCompress.Archives;
using SharpCompress.Archives.Rar; using SharpCompress.Archives.Rar;
using SharpCompress.Archives.SevenZip; using SharpCompress.Archives.SevenZip;
@ -146,6 +147,9 @@ public partial class TexToolsImporter
case ".mtrl": case ".mtrl":
_migrationManager.MigrateMtrlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions); _migrationManager.MigrateMtrlDuringExtraction(reader, _currentModDirectory!.FullName, _extractionOptions);
break; break;
case ".tex":
_migrationManager.FixMipMaps(reader, _currentModDirectory!.FullName, _extractionOptions);
break;
default: default:
reader.WriteEntryToDirectory(_currentModDirectory!.FullName, _extractionOptions); reader.WriteEntryToDirectory(_currentModDirectory!.FullName, _extractionOptions);
break; break;

View file

@ -1,4 +1,4 @@
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
using Penumbra.Import.Structs; using Penumbra.Import.Structs;

View file

@ -259,6 +259,7 @@ public partial class TexToolsImporter
{ {
".mdl" => _migrationManager.MigrateTtmpModel(extractedFile.FullName, data.Data), ".mdl" => _migrationManager.MigrateTtmpModel(extractedFile.FullName, data.Data),
".mtrl" => _migrationManager.MigrateTtmpMaterial(extractedFile.FullName, data.Data), ".mtrl" => _migrationManager.MigrateTtmpMaterial(extractedFile.FullName, data.Data),
".tex" => _migrationManager.FixTtmpMipMaps(extractedFile.FullName, data.Data),
_ => data.Data, _ => data.Data,
}; };

View file

@ -1,4 +1,4 @@
using ImGuiNET; using Dalamud.Bindings.ImGui;
using OtterGui.Raii; using OtterGui.Raii;
using OtterGui; using OtterGui;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;

View file

@ -61,6 +61,76 @@ public static class TexFileParser
return 13; return 13;
} }
public static unsafe void FixMipOffsets(long size, ref TexFile.TexHeader header, out long newSize)
{
var width = (uint)header.Width;
var height = (uint)header.Height;
var format = header.Format.ToDXGI();
var bits = format.BitsPerPixel();
var totalSize = 80u;
size -= totalSize;
var minSize = format.IsCompressed() ? 4u : 1u;
for (var i = 0; i < 13; ++i)
{
var requiredSize = (uint)((long)width * height * bits / 8);
if (requiredSize > size)
{
newSize = totalSize;
if (header.MipCount != i)
{
Penumbra.Log.Debug(
$"-- Mip Map Count in TEX header was {header.MipCount}, but file only contains data for {i} Mip Maps, fixed.");
FixLodOffsets(ref header, i);
}
return;
}
if (header.OffsetToSurface[i] != totalSize)
{
Penumbra.Log.Debug(
$"-- Mip Map Offset {i + 1} in TEX header was {header.OffsetToSurface[i]} but should be {totalSize}, fixed.");
header.OffsetToSurface[i] = totalSize;
}
if (width == minSize && height == minSize)
{
++i;
newSize = totalSize + requiredSize;
if (header.MipCount != i)
{
Penumbra.Log.Debug($"-- Reduced number of Mip Maps from {header.MipCount} to {i} due to minimum size constraints.");
FixLodOffsets(ref header, i);
}
return;
}
totalSize += requiredSize;
size -= requiredSize;
width = Math.Max(width / 2, minSize);
height = Math.Max(height / 2, minSize);
}
newSize = totalSize;
if (header.MipCount != 13)
{
Penumbra.Log.Debug($"-- Mip Map Count in TEX header was {header.MipCount}, but maximum is 13, fixed.");
FixLodOffsets(ref header, 13);
}
void FixLodOffsets(ref TexFile.TexHeader header, int index)
{
header.MipCount = index;
if (header.LodOffset[2] >= header.MipCount)
header.LodOffset[2] = (byte)(header.MipCount - 1);
if (header.LodOffset[1] >= header.MipCount)
header.LodOffset[1] = header.MipCount > 2 ? (byte)(header.MipCount - 2) : (byte)(header.MipCount - 1);
for (++index; index < 13; ++index)
header.OffsetToSurface[index] = 0;
}
}
private static unsafe void CopyData(ScratchImage image, BinaryReader r) private static unsafe void CopyData(ScratchImage image, BinaryReader r)
{ {
fixed (byte* ptr = image.Pixels) fixed (byte* ptr = image.Pixels)

View file

@ -1,5 +1,5 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface; using Dalamud.Interface;
using ImGuiNET;
using Lumina.Data.Files; using Lumina.Data.Files;
using OtterGui; using OtterGui;
using OtterGui.Raii; using OtterGui.Raii;
@ -20,7 +20,7 @@ public static class TextureDrawer
{ {
size = texture.TextureWrap.Size.Contain(size); size = texture.TextureWrap.Size.Contain(size);
ImGui.Image(texture.TextureWrap.ImGuiHandle, size); ImGui.Image(texture.TextureWrap.Handle, size);
DrawData(texture); DrawData(texture);
} }
else if (texture.LoadError != null) else if (texture.LoadError != null)

View file

@ -406,7 +406,7 @@ public sealed class TextureManager(IDataManager gameData, Logger logger, ITextur
// See https://github.com/microsoft/DirectXTex/wiki/Compress#parameters for the format condition. // See https://github.com/microsoft/DirectXTex/wiki/Compress#parameters for the format condition.
if (format is DXGIFormat.BC6HUF16 or DXGIFormat.BC6HSF16 or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB) if (format is DXGIFormat.BC6HUF16 or DXGIFormat.BC6HSF16 or DXGIFormat.BC7UNorm or DXGIFormat.BC7UNormSRGB)
{ {
var device = uiBuilder.Device; var device = new Device(uiBuilder.DeviceHandle);
var dxgiDevice = device.QueryInterface<DxgiDevice>(); var dxgiDevice = device.QueryInterface<DxgiDevice>();
using var deviceClone = new Device(dxgiDevice.Adapter, device.CreationFlags, device.FeatureLevel); using var deviceClone = new Device(dxgiDevice.Adapter, device.CreationFlags, device.FeatureLevel);

View file

@ -0,0 +1,47 @@
namespace Penumbra.Interop;
public static unsafe partial class CloudApi
{
private const int CfSyncRootInfoBasic = 0;
/// <summary> Determines whether a file or directory is cloud-synced using OneDrive or other providers that use the Cloud API. </summary>
/// <remarks> Can be expensive. Callers should cache the result when relevant. </remarks>
public static bool IsCloudSynced(string path)
{
var buffer = stackalloc long[1];
int hr;
uint length;
try
{
hr = CfGetSyncRootInfoByPath(path, CfSyncRootInfoBasic, buffer, sizeof(long), out length);
}
catch (DllNotFoundException)
{
Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw DllNotFoundException");
return false;
}
catch (EntryPointNotFoundException)
{
Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} threw EntryPointNotFoundException");
return false;
}
Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned HRESULT 0x{hr:X8}");
if (hr < 0)
return false;
if (length != sizeof(long))
{
Penumbra.Log.Debug($"Expected {nameof(CfGetSyncRootInfoByPath)} to return {sizeof(long)} bytes, got {length} bytes");
return false;
}
Penumbra.Log.Debug($"{nameof(CfGetSyncRootInfoByPath)} returned {{ SyncRootFileId = 0x{*buffer:X16} }}");
return true;
}
[LibraryImport("cldapi.dll", StringMarshalling = StringMarshalling.Utf16)]
private static partial int CfGetSyncRootInfoByPath(string filePath, int infoClass, void* infoBuffer, uint infoBufferLength,
out uint returnedLength);
}

View file

@ -59,9 +59,6 @@ public class GameState : IService
private readonly ThreadLocal<ResolveData> _characterSoundData = new(() => ResolveData.Invalid, true); private readonly ThreadLocal<ResolveData> _characterSoundData = new(() => ResolveData.Invalid, true);
public ResolveData SoundData
=> _animationLoadData.Value;
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)] [MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
public ResolveData SetSoundData(ResolveData data) public ResolveData SetSoundData(ResolveData data)
{ {

View file

@ -16,7 +16,7 @@ public sealed unsafe class ChangeCustomize : FastHook<ChangeCustomize.Delegate>
{ {
_collectionResolver = collectionResolver; _collectionResolver = collectionResolver;
_metaState = metaState; _metaState = metaState;
Task = hooks.CreateHook<Delegate>("Change Customize", Sigs.ChangeCustomize, Detour, !HookOverrides.Instance.Meta.ChangeCustomize); Task = hooks.CreateHook<Delegate>("Change Customize", Sigs.UpdateDrawData, Detour, !HookOverrides.Instance.Meta.ChangeCustomize);
} }
public delegate bool Delegate(Human* human, CustomizeArray* data, byte skipEquipment); public delegate bool Delegate(Human* human, CustomizeArray* data, byte skipEquipment);

View file

@ -35,14 +35,14 @@ public sealed unsafe class WeaponReload : EventWrapperPtr<DrawDataContainer, Cha
public bool Finished public bool Finished
=> _task.IsCompletedSuccessfully; => _task.IsCompletedSuccessfully;
private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g); private delegate void Delegate(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h);
private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g) private void Detour(DrawDataContainer* drawData, uint slot, ulong weapon, byte d, byte e, byte f, byte g, byte h)
{ {
var gameObject = drawData->OwnerObject; var gameObject = drawData->OwnerObject;
Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}."); Penumbra.Log.Verbose($"[{Name}] Triggered with drawData: 0x{(nint)drawData:X}, {slot}, {weapon}, {d}, {e}, {f}, {g}, {h}.");
Invoke(drawData, gameObject, (CharacterWeapon*)(&weapon)); Invoke(drawData, gameObject, (CharacterWeapon*)(&weapon));
_task.Result.Original(drawData, slot, weapon, d, e, f, g); _task.Result.Original(drawData, slot, weapon, d, e, f, g, h);
_postEvent.Invoke(drawData, gameObject); _postEvent.Invoke(drawData, gameObject);
} }

View file

@ -63,7 +63,7 @@ public sealed unsafe class PreBoneDeformerReplacer : IDisposable, IRequiredServi
if (!_framework.IsInFrameworkUpdateThread) if (!_framework.IsInFrameworkUpdateThread)
Penumbra.Log.Warning( Penumbra.Log.Warning(
$"{nameof(PreBoneDeformerReplacer)}.{nameof(SetupHssReplacements)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread"); $"{nameof(PreBoneDeformerReplacer)}.{nameof(SetupHssReplacements)}(0x{(nint)drawObject:X}, {slotIndex}) called out of framework thread");
var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject); var preBoneDeformer = GetPreBoneDeformerForCharacter(drawObject);
try try
{ {

View file

@ -1,4 +1,5 @@
using Dalamud.Hooking; using Dalamud.Hooking;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Dalamud.Utility.Signatures; using Dalamud.Utility.Signatures;
using FFXIVClientStructs.FFXIV.Client.System.Resource; using FFXIVClientStructs.FFXIV.Client.System.Resource;
@ -85,7 +86,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, nint unk7, uint unk8); ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, nint unk7, uint unk8);
private delegate ResourceHandle* GetResourceAsyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId, private delegate ResourceHandle* GetResourceAsyncPrototype(ResourceManager* resourceManager, ResourceCategory* pCategoryId,
ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8, uint unk9); ResourceType* pResourceType, int* pResourceHash, byte* pPath, GetResourceParameters* pGetResParams, byte isUnknown, nint unk8,
uint unk9);
[Signature(Sigs.GetResourceSync, DetourName = nameof(GetResourceSyncDetour))] [Signature(Sigs.GetResourceSync, DetourName = nameof(GetResourceSyncDetour))]
private readonly Hook<GetResourceSyncPrototype> _getResourceSyncHook = null!; private readonly Hook<GetResourceSyncPrototype> _getResourceSyncHook = null!;
@ -118,18 +120,26 @@ public unsafe class ResourceService : IDisposable, IRequiredService
unk9); unk9);
} }
var original = gamePath; if (gamePath.IsEmpty)
{
Penumbra.Log.Error($"[ResourceService] Empty resource path requested with category {*categoryId}, type {*resourceType}, hash {*resourceHash}.");
return null;
}
var original = gamePath;
ResourceHandle* returnValue = null; ResourceHandle* returnValue = null;
ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync, ResourceRequested?.Invoke(ref *categoryId, ref *resourceType, ref *resourceHash, ref gamePath, original, pGetResParams, ref isSync,
ref returnValue); ref returnValue);
if (returnValue != null) if (returnValue != null)
return returnValue; return returnValue;
return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8, unk9); return GetOriginalResource(isSync, *categoryId, *resourceType, *resourceHash, gamePath.Path, original, pGetResParams, isUnk, unk8,
unk9);
} }
/// <summary> Call the original GetResource function. </summary> /// <summary> Call the original GetResource function. </summary>
public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path, Utf8GamePath original, public ResourceHandle* GetOriginalResource(bool sync, ResourceCategory categoryId, ResourceType type, int hash, CiByteString path,
Utf8GamePath original,
GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0) GetResourceParameters* resourceParameters = null, byte unk = 0, nint unk8 = 0, uint unk9 = 0)
{ {
var previous = _currentGetResourcePath.Value; var previous = _currentGetResourcePath.Value;
@ -141,7 +151,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
resourceParameters, unk8, unk9) resourceParameters, unk8, unk9)
: _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path, : _getResourceAsyncHook.OriginalDisposeSafe(_resourceManager.ResourceManager, &categoryId, &type, &hash, path.Path,
resourceParameters, unk, unk8, unk9); resourceParameters, unk, unk8, unk9);
} finally }
finally
{ {
_currentGetResourcePath.Value = previous; _currentGetResourcePath.Value = previous;
} }
@ -163,7 +174,8 @@ public unsafe class ResourceService : IDisposable, IRequiredService
/// <param name="syncOriginal">The original game path of the resource, if loaded synchronously.</param> /// <param name="syncOriginal">The original game path of the resource, if loaded synchronously.</param>
/// <param name="previousState">The previous state of the resource.</param> /// <param name="previousState">The previous state of the resource.</param>
/// <param name="returnValue">The return value to use.</param> /// <param name="returnValue">The return value to use.</param>
public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal, (byte UnkState, LoadState LoadState) previousState, ref uint returnValue); public delegate void ResourceStateUpdatedDelegate(ResourceHandle* handle, Utf8GamePath syncOriginal,
(byte UnkState, LoadState LoadState) previousState, ref uint returnValue);
/// <summary> /// <summary>
/// <inheritdoc cref="ResourceStateUpdatingDelegate"/> <para/> /// <inheritdoc cref="ResourceStateUpdatingDelegate"/> <para/>
@ -185,7 +197,7 @@ public unsafe class ResourceService : IDisposable, IRequiredService
private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread) private uint UpdateResourceStateDetour(ResourceHandle* handle, byte offFileThread)
{ {
var previousState = (handle->UnkState, handle->LoadState); var previousState = (handle->UnkState, handle->LoadState);
var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty; var syncOriginal = _currentGetResourcePath.IsValueCreated ? _currentGetResourcePath.Value : Utf8GamePath.Empty;
ResourceStateUpdating?.Invoke(handle, syncOriginal); ResourceStateUpdating?.Invoke(handle, syncOriginal);
var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread); var ret = _updateResourceStateHook.OriginalDisposeSafe(handle, offFileThread);
ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret); ResourceStateUpdated?.Invoke(handle, syncOriginal, previousState, ref ret);

View file

@ -6,6 +6,7 @@ using Penumbra.Collections;
using Penumbra.GameData.Enums; using Penumbra.GameData.Enums;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Interop.PathResolving; using Penumbra.Interop.PathResolving;
using Penumbra.Interop.Processing;
using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler; using static FFXIVClientStructs.FFXIV.Client.Game.Character.ActionEffectHandler;
namespace Penumbra.Interop.Hooks.Resources; namespace Penumbra.Interop.Hooks.Resources;
@ -35,6 +36,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
private readonly Hook<MPapResolveDelegate> _resolveMPapPathHook; private readonly Hook<MPapResolveDelegate> _resolveMPapPathHook;
private readonly Hook<PerSlotResolveDelegate> _resolveMdlPathHook; private readonly Hook<PerSlotResolveDelegate> _resolveMdlPathHook;
private readonly Hook<NamedResolveDelegate> _resolveMtrlPathHook; private readonly Hook<NamedResolveDelegate> _resolveMtrlPathHook;
private readonly Hook<PerSlotResolveDelegate> _resolveSkinMtrlPathHook;
private readonly Hook<NamedResolveDelegate> _resolvePapPathHook; private readonly Hook<NamedResolveDelegate> _resolvePapPathHook;
private readonly Hook<PerSlotResolveDelegate> _resolveKdbPathHook; private readonly Hook<PerSlotResolveDelegate> _resolveKdbPathHook;
private readonly Hook<PerSlotResolveDelegate> _resolvePhybPathHook; private readonly Hook<PerSlotResolveDelegate> _resolvePhybPathHook;
@ -52,22 +54,23 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
{ {
_parent = parent; _parent = parent;
// @formatter:off // @formatter:off
_resolveSklbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveSklb)}", hooks, vTable[76], type, ResolveSklb, ResolveSklbHuman); _resolveSklbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveSklb)}", hooks, vTable[76], type, ResolveSklb, ResolveSklbHuman);
_resolveMdlPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveMdl)}", hooks, vTable[77], type, ResolveMdl, ResolveMdlHuman); _resolveMdlPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveMdl)}", hooks, vTable[77], type, ResolveMdl, ResolveMdlHuman);
_resolveSkpPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveSkp)}", hooks, vTable[78], type, ResolveSkp, ResolveSkpHuman); _resolveSkpPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveSkp)}", hooks, vTable[78], type, ResolveSkp, ResolveSkpHuman);
_resolvePhybPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolvePhyb)}", hooks, vTable[79], type, ResolvePhyb, ResolvePhybHuman); _resolvePhybPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolvePhyb)}", hooks, vTable[79], type, ResolvePhyb, ResolvePhybHuman);
_resolveKdbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveKdb)}", hooks, vTable[80], type, ResolveKdb, ResolveKdbHuman); _resolveKdbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveKdb)}", hooks, vTable[80], type, ResolveKdb, ResolveKdbHuman);
_vFunc81Hook = Create<SkeletonVFuncDelegate>( $"{name}.{nameof(VFunc81)}", hooks, vTable[81], type, null, VFunc81); _vFunc81Hook = Create<SkeletonVFuncDelegate>( $"{name}.{nameof(VFunc81)}", hooks, vTable[81], type, null, VFunc81);
_resolveBnmbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveBnmb)}", hooks, vTable[82], type, ResolveBnmb, ResolveBnmbHuman); _resolveBnmbPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveBnmb)}", hooks, vTable[82], type, ResolveBnmb, ResolveBnmbHuman);
_vFunc83Hook = Create<SkeletonVFuncDelegate>( $"{name}.{nameof(VFunc83)}", hooks, vTable[83], type, null, VFunc83); _vFunc83Hook = Create<SkeletonVFuncDelegate>( $"{name}.{nameof(VFunc83)}", hooks, vTable[83], type, null, VFunc83);
_resolvePapPathHook = Create<NamedResolveDelegate>( $"{name}.{nameof(ResolvePap)}", hooks, vTable[84], type, ResolvePap, ResolvePapHuman); _resolvePapPathHook = Create<NamedResolveDelegate>( $"{name}.{nameof(ResolvePap)}", hooks, vTable[84], type, ResolvePap, ResolvePapHuman);
_resolveTmbPathHook = Create<TmbResolveDelegate>( $"{name}.{nameof(ResolveTmb)}", hooks, vTable[85], ResolveTmb); _resolveTmbPathHook = Create<TmbResolveDelegate>( $"{name}.{nameof(ResolveTmb)}", hooks, vTable[85], ResolveTmb);
_resolveMPapPathHook = Create<MPapResolveDelegate>( $"{name}.{nameof(ResolveMPap)}", hooks, vTable[87], ResolveMPap); _resolveMPapPathHook = Create<MPapResolveDelegate>( $"{name}.{nameof(ResolveMPap)}", hooks, vTable[87], ResolveMPap);
_resolveImcPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveImc)}", hooks, vTable[89], ResolveImc); _resolveImcPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveImc)}", hooks, vTable[89], ResolveImc);
_resolveMtrlPathHook = Create<NamedResolveDelegate>( $"{name}.{nameof(ResolveMtrl)}", hooks, vTable[90], ResolveMtrl); _resolveMtrlPathHook = Create<NamedResolveDelegate>( $"{name}.{nameof(ResolveMtrl)}", hooks, vTable[90], ResolveMtrl);
_resolveDecalPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveDecal)}", hooks, vTable[92], ResolveDecal); _resolveSkinMtrlPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveSkinMtrl)}", hooks, vTable[91], ResolveSkinMtrl);
_resolveVfxPathHook = Create<VfxResolveDelegate>( $"{name}.{nameof(ResolveVfx)}", hooks, vTable[93], type, ResolveVfx, ResolveVfxHuman); _resolveDecalPathHook = Create<PerSlotResolveDelegate>($"{name}.{nameof(ResolveDecal)}", hooks, vTable[92], ResolveDecal);
_resolveEidPathHook = Create<SingleResolveDelegate>( $"{name}.{nameof(ResolveEid)}", hooks, vTable[94], ResolveEid); _resolveVfxPathHook = Create<VfxResolveDelegate>( $"{name}.{nameof(ResolveVfx)}", hooks, vTable[93], type, ResolveVfx, ResolveVfxHuman);
_resolveEidPathHook = Create<SingleResolveDelegate>( $"{name}.{nameof(ResolveEid)}", hooks, vTable[94], ResolveEid);
// @formatter:on // @formatter:on
@ -83,6 +86,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
_resolveMPapPathHook.Enable(); _resolveMPapPathHook.Enable();
_resolveMdlPathHook.Enable(); _resolveMdlPathHook.Enable();
_resolveMtrlPathHook.Enable(); _resolveMtrlPathHook.Enable();
_resolveSkinMtrlPathHook.Enable();
_resolvePapPathHook.Enable(); _resolvePapPathHook.Enable();
_resolveKdbPathHook.Enable(); _resolveKdbPathHook.Enable();
_resolvePhybPathHook.Enable(); _resolvePhybPathHook.Enable();
@ -103,6 +107,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
_resolveMPapPathHook.Disable(); _resolveMPapPathHook.Disable();
_resolveMdlPathHook.Disable(); _resolveMdlPathHook.Disable();
_resolveMtrlPathHook.Disable(); _resolveMtrlPathHook.Disable();
_resolveSkinMtrlPathHook.Disable();
_resolvePapPathHook.Disable(); _resolvePapPathHook.Disable();
_resolveKdbPathHook.Disable(); _resolveKdbPathHook.Disable();
_resolvePhybPathHook.Disable(); _resolvePhybPathHook.Disable();
@ -123,6 +128,7 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
_resolveMPapPathHook.Dispose(); _resolveMPapPathHook.Dispose();
_resolveMdlPathHook.Dispose(); _resolveMdlPathHook.Dispose();
_resolveMtrlPathHook.Dispose(); _resolveMtrlPathHook.Dispose();
_resolveSkinMtrlPathHook.Dispose();
_resolvePapPathHook.Dispose(); _resolvePapPathHook.Dispose();
_resolveKdbPathHook.Dispose(); _resolveKdbPathHook.Dispose();
_resolvePhybPathHook.Dispose(); _resolvePhybPathHook.Dispose();
@ -153,6 +159,15 @@ public sealed unsafe class ResolvePathHooksBase : IDisposable
private nint ResolveMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint mtrlFileName) private nint ResolveMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex, nint mtrlFileName)
=> ResolvePath(drawObject, _resolveMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, mtrlFileName)); => ResolvePath(drawObject, _resolveMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex, mtrlFileName));
private nint ResolveSkinMtrl(nint drawObject, nint pathBuffer, nint pathBufferSize, uint slotIndex)
{
var finalPathBuffer = _resolveSkinMtrlPathHook.Original(drawObject, pathBuffer, pathBufferSize, slotIndex);
if (DebugConfiguration.UseSkinMaterialProcessing && finalPathBuffer != nint.Zero && finalPathBuffer == pathBuffer)
SkinMtrlPathEarlyProcessing.Process(new Span<byte>((void*)pathBuffer, (int)pathBufferSize), (CharacterBase*)drawObject, slotIndex);
return ResolvePath(drawObject, finalPathBuffer);
}
private nint ResolvePap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName) private nint ResolvePap(nint drawObject, nint pathBuffer, nint pathBufferSize, uint unkAnimationIndex, nint animationName)
=> ResolvePath(drawObject, _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName)); => ResolvePath(drawObject, _resolvePapPathHook.Original(drawObject, pathBuffer, pathBufferSize, unkAnimationIndex, animationName));

View file

@ -85,7 +85,7 @@ public readonly record struct MaterialInfo(ObjectIndex ObjectIndex, DrawObjectTy
if (mtrlHandle == null) if (mtrlHandle == null)
continue; continue;
PathDataHandler.Split(mtrlHandle->ResourceHandle.FileName.AsSpan(), out var path, out _); PathDataHandler.Split(mtrlHandle->FileName.AsSpan(), out var path, out _);
var fileName = CiByteString.FromSpanUnsafe(path, true); var fileName = CiByteString.FromSpanUnsafe(path, true);
if (fileName == needle) if (fileName == needle)
result.Add(new MaterialInfo(index, type, i, j)); result.Add(new MaterialInfo(index, type, i, j));

View file

@ -137,7 +137,7 @@ public sealed unsafe class CollectionResolver(
{ {
var item = charaEntry.Value; var item = charaEntry.Value;
var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId); var identifier = actors.CreatePlayer(new ByteString(item->Name), item->HomeWorldId);
Penumbra.Log.Verbose( Penumbra.Log.Excessive(
$"Identified {identifier.Incognito(null)} in cutscene for actor {idx + 200} at 0x{(ulong)gameObject:X} of race {(gameObject->IsCharacter() ? ((Character*)gameObject)->DrawData.CustomizeData.Race.ToString() : "Unknown")}."); $"Identified {identifier.Incognito(null)} in cutscene for actor {idx + 200} at 0x{(ulong)gameObject:X} of race {(gameObject->IsCharacter() ? ((Character*)gameObject)->DrawData.CustomizeData.Race.ToString() : "Unknown")}.");
if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll) if (identifier.IsValid && CollectionByIdentifier(identifier) is { } coll)
{ {

View file

@ -75,6 +75,7 @@ public sealed class CutsceneService : IRequiredService, IDisposable
return false; return false;
_copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx; _copiedCharacters[copyIdx - CutsceneStartIdx] = (short)parentIdx;
_objects.InvokeRequiredUpdates();
return true; return true;
} }

View file

@ -0,0 +1,7 @@
namespace Penumbra.Interop;
public static partial class ProcessThreadApi
{
[LibraryImport("kernel32.dll")]
public static partial uint GetCurrentThreadId();
}

View file

@ -0,0 +1,119 @@
using Dalamud.Game;
using Dalamud.Plugin.Services;
using Penumbra.Api.Enums;
using Penumbra.GameData;
using Penumbra.GameData.Data;
using Penumbra.Interop.Structs;
using Penumbra.Meta.Files;
using Penumbra.String;
namespace Penumbra.Interop.Processing;
public sealed class PbdFilePostProcessor : IFilePostProcessor
{
private readonly IFileAllocator _allocator;
private byte[] _epbdData;
private unsafe delegate* unmanaged<ResourceHandle*, void> _loadEpbdData;
public ResourceType Type
=> ResourceType.Pbd;
public unsafe PbdFilePostProcessor(IDataManager dataManager, XivFileAllocator allocator, ISigScanner scanner)
{
_allocator = allocator;
_epbdData = SetEpbdData(dataManager);
_loadEpbdData = (delegate* unmanaged<ResourceHandle*, void>)scanner.ScanText(Sigs.LoadEpbdData);
}
public unsafe void PostProcess(ResourceHandle* resource, CiByteString originalGamePath, ReadOnlySpan<byte> additionalData)
{
if (_epbdData.Length is 0)
return;
if (resource->LoadState is not LoadState.Success)
{
Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} failed load ({resource->LoadState}).");
return;
}
var (data, length) = resource->GetData();
if (length is 0 || data == nint.Zero)
{
Penumbra.Log.Warning($"[ResourceLoader] Requested PBD at {resource->FileName()} succeeded load but has no data.");
return;
}
var span = new ReadOnlySpan<byte>((void*)data, (int)resource->FileSize);
var reader = new PackReader(span);
if (reader.HasData)
{
Penumbra.Log.Excessive($"[ResourceLoader] Successfully loaded PBD at {resource->FileName()} with EPBD data.");
return;
}
var newData = AppendData(span);
fixed (byte* ptr = newData)
{
// Set the appended data and the actual file size, then re-load the EPBD data via game function call.
if (resource->SetData((nint)ptr, newData.Length))
{
resource->FileSize = (uint)newData.Length;
resource->CsHandle.FileSize2 = (uint)newData.Length;
resource->CsHandle.FileSize3 = (uint)newData.Length;
_loadEpbdData(resource);
// Free original data.
_allocator.Release((void*)data, length);
Penumbra.Log.Debug($"[ResourceLoader] Loaded {resource->FileName()} from file and appended default EPBD data.");
}
else
{
Penumbra.Log.Warning(
$"[ResourceLoader] Failed to append EPBD data to custom PBD at {resource->FileName()}.");
}
}
}
/// <summary> Combine the given data with the default PBD data using the game's file allocator. </summary>
private unsafe ReadOnlySpan<byte> AppendData(ReadOnlySpan<byte> data)
{
// offset has to be set, otherwise not called.
var newLength = data.Length + _epbdData.Length;
var memory = _allocator.Allocate(newLength);
var span = new Span<byte>(memory, newLength);
data.CopyTo(span);
_epbdData.CopyTo(span[data.Length..]);
return span;
}
/// <summary> Fetch the default EPBD data from the .pbd file of the game's installation. </summary>
private static byte[] SetEpbdData(IDataManager dataManager)
{
try
{
var file = dataManager.GetFile(GamePaths.Pbd.Path);
if (file is null || file.Data.Length is 0)
{
Penumbra.Log.Warning("Default PBD file has no data.");
return [];
}
ReadOnlySpan<byte> span = file.Data;
var reader = new PackReader(span);
if (!reader.HasData)
{
Penumbra.Log.Warning("Default PBD file has no EPBD section.");
return [];
}
var offset = span.Length - (int)reader.PackLength;
var ret = span[offset..];
Penumbra.Log.Verbose($"Default PBD file has EPBD section of length {ret.Length} at offset {offset}.");
return ret.ToArray();
}
catch (Exception ex)
{
Penumbra.Log.Error($"Unknown error getting default EPBD data:\n{ex}");
return [];
}
}
}

View file

@ -0,0 +1,63 @@
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using FFXIVClientStructs.FFXIV.Client.System.Resource.Handle;
namespace Penumbra.Interop.Processing;
public static unsafe class SkinMtrlPathEarlyProcessing
{
public static void Process(Span<byte> path, CharacterBase* character, uint slotIndex)
{
var end = path.IndexOf(MaterialExtension());
if (end < 0)
return;
var suffixPos = path[..end].LastIndexOf((byte)'_');
if (suffixPos < 0)
return;
var handle = GetModelResourceHandle(character, slotIndex);
if (handle == null)
return;
var skinSuffix = GetSkinSuffix(handle);
if (skinSuffix.IsEmpty || skinSuffix.Length > path.Length - suffixPos - 7)
return;
++suffixPos;
skinSuffix.CopyTo(path[suffixPos..]);
suffixPos += skinSuffix.Length;
MaterialExtension().CopyTo(path[suffixPos..]);
return;
static ReadOnlySpan<byte> MaterialExtension()
=> ".mtrl\0"u8;
}
private static ModelResourceHandle* GetModelResourceHandle(CharacterBase* character, uint slotIndex)
{
if (character is null)
return null;
if (character->PerSlotStagingArea is not null)
{
var handle = character->PerSlotStagingArea[slotIndex].ModelResourceHandle;
if (handle != null)
return handle;
}
var model = character->Models[slotIndex];
return model is null ? null : model->ModelResourceHandle;
}
private static ReadOnlySpan<byte> GetSkinSuffix(ModelResourceHandle* handle)
{
foreach (var (attribute, _) in handle->Attributes)
{
var attributeSpan = attribute.AsSpan();
if (attributeSpan.Length > 12 && attributeSpan[..11].SequenceEqual("skin_suffix"u8) && attributeSpan[11] is (byte)'=' or (byte)'_')
return attributeSpan[12..];
}
return [];
}
}

View file

@ -338,6 +338,34 @@ internal partial record ResolveContext
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty; return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
} }
private Utf8GamePath ResolveKineDriverModulePath(uint partialSkeletonIndex)
{
// Correctness and Safety:
// Resolving a KineDriver module path through the game's code can use EST metadata for human skeletons.
// Additionally, it can dereference null pointers for human equipment skeletons.
return ModelType switch
{
ModelType.Human => ResolveHumanKineDriverModulePath(partialSkeletonIndex),
_ => ResolveKineDriverModulePathNative(partialSkeletonIndex),
};
}
private Utf8GamePath ResolveHumanKineDriverModulePath(uint partialSkeletonIndex)
{
var (raceCode, slot, set) = ResolveHumanSkeletonData(partialSkeletonIndex);
if (set.Id is 0)
return Utf8GamePath.Empty;
var path = GamePaths.Kdb.Customization(raceCode, slot, set);
return Utf8GamePath.FromString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveKineDriverModulePathNative(uint partialSkeletonIndex)
{
var path = CharacterBase->ResolveKdbPathAsByteString(partialSkeletonIndex);
return Utf8GamePath.FromByteString(path, out var gamePath) ? gamePath : Utf8GamePath.Empty;
}
private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc) private unsafe Utf8GamePath ResolveMaterialAnimationPath(ResourceHandle* imc)
{ {
var animation = ResolveImcData(imc).MaterialAnimationId; var animation = ResolveImcData(imc).MaterialAnimationId;

View file

@ -59,7 +59,7 @@ internal unsafe partial record ResolveContext(
if (!Utf8GamePath.FromByteString(CiByteString.Join((byte)'/', ShpkPrefix, gamePath), out var path)) if (!Utf8GamePath.FromByteString(CiByteString.Join((byte)'/', ShpkPrefix, gamePath), out var path))
return null; return null;
return GetOrCreateNode(ResourceType.Shpk, (nint)resourceHandle->ShaderPackage, &resourceHandle->ResourceHandle, path); return GetOrCreateNode(ResourceType.Shpk, (nint)resourceHandle->ShaderPackage, (ResourceHandle*)resourceHandle, path);
} }
[SkipLocalsInit] [SkipLocalsInit]
@ -188,7 +188,8 @@ internal unsafe partial record ResolveContext(
return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, gamePath); return GetOrCreateNode(ResourceType.Tex, (nint)tex->Texture, &tex->ResourceHandle, gamePath);
} }
public ResourceNode? CreateNodeFromModel(Model* mdl, ResourceHandle* imc, TextureResourceHandle* decalHandle, ResourceHandle* mpapHandle) public ResourceNode? CreateNodeFromModel(Model* mdl, ResourceHandle* imc, TextureResourceHandle* decalHandle,
MaterialResourceHandle* skinMtrlHandle, ResourceHandle* mpapHandle)
{ {
if (mdl is null || mdl->ModelResourceHandle is null) if (mdl is null || mdl->ModelResourceHandle is null)
return null; return null;
@ -218,6 +219,12 @@ internal unsafe partial record ResolveContext(
} }
} }
if (skinMtrlHandle is not null
&& Utf8GamePath.FromByteString(CharacterBase->ResolveSkinMtrlPathAsByteString(SlotIndex), out var skinMtrlPath)
&& CreateNodeFromMaterial(skinMtrlHandle->Material, skinMtrlPath) is
{ } skinMaaterialNode)
node.Children.Add(skinMaaterialNode);
if (CreateNodeFromDecal(decalHandle, imc) is { } decalNode) if (CreateNodeFromDecal(decalHandle, imc) is { } decalNode)
node.Children.Add(decalNode); node.Children.Add(decalNode);
@ -238,7 +245,7 @@ internal unsafe partial record ResolveContext(
if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached)) if (Global.Nodes.TryGetValue((path, (nint)resource), out var cached))
return cached; return cached;
var node = CreateNode(ResourceType.Mtrl, (nint)mtrl, &resource->ResourceHandle, path, false); var node = CreateNode(ResourceType.Mtrl, (nint)mtrl, (ResourceHandle*)resource, path, false);
var shpkNode = CreateNodeFromShpk(resource->ShaderPackageResourceHandle, new CiByteString(resource->ShpkName.Value)); var shpkNode = CreateNodeFromShpk(resource->ShaderPackageResourceHandle, new CiByteString(resource->ShpkName.Value));
if (shpkNode is not null) if (shpkNode is not null)
{ {
@ -364,7 +371,8 @@ internal unsafe partial record ResolveContext(
return node; return node;
} }
public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, uint partialSkeletonIndex) public ResourceNode? CreateNodeFromPartialSkeleton(PartialSkeleton* sklb, ResourceHandle* phybHandle, ResourceHandle* kdbHandle,
uint partialSkeletonIndex)
{ {
if (sklb is null || sklb->SkeletonResourceHandle is null) if (sklb is null || sklb->SkeletonResourceHandle is null)
return null; return null;
@ -379,6 +387,8 @@ internal unsafe partial record ResolveContext(
node.Children.Add(skpNode); node.Children.Add(skpNode);
if (CreateNodeFromPhyb(phybHandle, partialSkeletonIndex) is { } phybNode) if (CreateNodeFromPhyb(phybHandle, partialSkeletonIndex) is { } phybNode)
node.Children.Add(phybNode); node.Children.Add(phybNode);
if (CreateNodeFromKdb(kdbHandle, partialSkeletonIndex) is { } kdbNode)
node.Children.Add(kdbNode);
Global.Nodes.Add((path, (nint)sklb->SkeletonResourceHandle), node); Global.Nodes.Add((path, (nint)sklb->SkeletonResourceHandle), node);
return node; return node;
@ -420,6 +430,24 @@ internal unsafe partial record ResolveContext(
return node; return node;
} }
private ResourceNode? CreateNodeFromKdb(ResourceHandle* kdbHandle, uint partialSkeletonIndex)
{
if (kdbHandle is null)
return null;
var path = ResolveKineDriverModulePath(partialSkeletonIndex);
if (Global.Nodes.TryGetValue((path, (nint)kdbHandle), out var cached))
return cached;
var node = CreateNode(ResourceType.Kdb, 0, kdbHandle, path, false);
if (Global.WithUiData)
node.FallbackName = "KineDriver Module";
Global.Nodes.Add((path, (nint)kdbHandle), node);
return node;
}
internal ResourceNode.UiData GuessModelUiData(Utf8GamePath gamePath) internal ResourceNode.UiData GuessModelUiData(Utf8GamePath gamePath)
{ {
var path = gamePath.Path.Split((byte)'/'); var path = gamePath.Path.Split((byte)'/');

View file

@ -45,7 +45,9 @@ public class ResourceNode : ICloneable
/// <summary> Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). </summary> /// <summary> Whether to treat the file as protected (require holding the Mod Deletion Modifier to make a quick import). </summary>
public bool Protected public bool Protected
=> ForceProtected || Internal || Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Pbd; => ForceProtected
|| Internal
|| Type is ResourceType.Shpk or ResourceType.Sklb or ResourceType.Skp or ResourceType.Phyb or ResourceType.Kdb or ResourceType.Pbd;
internal ResourceNode(ResourceType type, nint objectAddress, nint resourceHandle, ulong length, ResolveContext? resolveContext) internal ResourceNode(ResourceType type, nint objectAddress, nint resourceHandle, ulong length, ResolveContext? resolveContext)
{ {

View file

@ -70,12 +70,16 @@ public class ResourceTree(
var genericContext = globalContext.CreateContext(model); var genericContext = globalContext.CreateContext(model);
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1312) var mpapArrayPtr = model->MaterialAnimationPacks;
var mpapArrayPtr = *(ResourceHandle***)((nint)model + 0x948);
var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan<Pointer<ResourceHandle>>(mpapArrayPtr, model->SlotCount) : []; var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan<Pointer<ResourceHandle>>(mpapArrayPtr, model->SlotCount) : [];
var skinMtrlArray = modelType switch
{
ModelType.Human => ((Human*) model)->SlotSkinMaterials,
_ => [],
};
var decalArray = modelType switch var decalArray = modelType switch
{ {
ModelType.Human => human->SlotDecalsSpan, ModelType.Human => human->SlotDecals,
ModelType.DemiHuman => ((Demihuman*)model)->SlotDecals, ModelType.DemiHuman => ((Demihuman*)model)->SlotDecals,
ModelType.Weapon => [((Weapon*)model)->Decal], ModelType.Weapon => [((Weapon*)model)->Decal],
ModelType.Monster => [((Monster*)model)->Decal], ModelType.Monster => [((Monster*)model)->Decal],
@ -108,7 +112,8 @@ public class ResourceTree(
var mdl = model->Models[i]; var mdl = model->Models[i];
if (slotContext.CreateNodeFromModel(mdl, imc, i < decalArray.Length ? decalArray[(int)i].Value : null, if (slotContext.CreateNodeFromModel(mdl, imc, i < decalArray.Length ? decalArray[(int)i].Value : null,
i < mpapArray.Length ? mpapArray[(int)i].Value : null) is { } mdlNode) i < skinMtrlArray.Length ? skinMtrlArray[(int)i].Value : null, i < mpapArray.Length ? mpapArray[(int)i].Value : null) is
{ } mdlNode)
{ {
if (globalContext.WithUiData) if (globalContext.WithUiData)
mdlNode.FallbackName = $"Model #{i}"; mdlNode.FallbackName = $"Model #{i}";
@ -116,9 +121,8 @@ public class ResourceTree(
} }
} }
AddSkeleton(Nodes, genericContext, model->EID, model->Skeleton, model->BonePhysicsModule); AddSkeleton(Nodes, genericContext, model);
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1312) AddMaterialAnimationSkeleton(Nodes, genericContext, model->MaterialAnimationSkeleton);
AddMaterialAnimationSkeleton(Nodes, genericContext, *(SkeletonResourceHandle**)((nint)model + 0x940));
AddWeapons(globalContext, model); AddWeapons(globalContext, model);
@ -149,8 +153,7 @@ public class ResourceTree(
var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType); var genericContext = globalContext.CreateContext(subObject, 0xFFFFFFFFu, slot, equipment, weaponType);
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1312) var mpapArrayPtr = subObject->MaterialAnimationPacks;
var mpapArrayPtr = *(ResourceHandle***)((nint)subObject + 0x948);
var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan<Pointer<ResourceHandle>>(mpapArrayPtr, subObject->SlotCount) : []; var mpapArray = mpapArrayPtr is not null ? new ReadOnlySpan<Pointer<ResourceHandle>>(mpapArrayPtr, subObject->SlotCount) : [];
for (var i = 0; i < subObject->SlotCount; ++i) for (var i = 0; i < subObject->SlotCount; ++i)
@ -166,7 +169,8 @@ public class ResourceTree(
} }
var mdl = subObject->Models[i]; var mdl = subObject->Models[i];
if (slotContext.CreateNodeFromModel(mdl, imc, weapon->Decal, i < mpapArray.Length ? mpapArray[i].Value : null) is { } mdlNode) if (slotContext.CreateNodeFromModel(mdl, imc, weapon->Decal, null, i < mpapArray.Length ? mpapArray[i].Value : null) is
{ } mdlNode)
{ {
if (globalContext.WithUiData) if (globalContext.WithUiData)
mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}"; mdlNode.FallbackName = $"Weapon #{weaponIndex}, Model #{i}";
@ -174,10 +178,8 @@ public class ResourceTree(
} }
} }
AddSkeleton(weaponNodes, genericContext, subObject->EID, subObject->Skeleton, subObject->BonePhysicsModule, AddSkeleton(weaponNodes, genericContext, subObject, $"Weapon #{weaponIndex}, ");
$"Weapon #{weaponIndex}, "); AddMaterialAnimationSkeleton(weaponNodes, genericContext, subObject->MaterialAnimationSkeleton,
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1312)
AddMaterialAnimationSkeleton(weaponNodes, genericContext, *(SkeletonResourceHandle**)((nint)subObject + 0x940),
$"Weapon #{weaponIndex}, "); $"Weapon #{weaponIndex}, ");
++weaponIndex; ++weaponIndex;
@ -239,8 +241,11 @@ public class ResourceTree(
} }
} }
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, CharacterBase* model, string prefix = "")
=> AddSkeleton(nodes, context, model->EID, model->Skeleton, model->BonePhysicsModule, model->BoneKineDriverModule, prefix);
private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics, private unsafe void AddSkeleton(List<ResourceNode> nodes, ResolveContext context, void* eid, Skeleton* skeleton, BonePhysicsModule* physics,
string prefix = "") BoneKineDriverModule* kineDriver, string prefix = "")
{ {
var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid); var eidNode = context.CreateNodeFromEid((ResourceHandle*)eid);
if (eidNode != null) if (eidNode != null)
@ -255,9 +260,9 @@ public class ResourceTree(
for (var i = 0; i < skeleton->PartialSkeletonCount; ++i) for (var i = 0; i < skeleton->PartialSkeletonCount; ++i)
{ {
// TODO ClientStructs-ify (aers/FFXIVClientStructs#1312) var phybHandle = physics != null ? physics->BonePhysicsResourceHandles[i] : null;
var phybHandle = physics != null ? ((ResourceHandle**)((nint)physics + 0x190))[i] : null; var kdbHandle = kineDriver != null ? kineDriver->PartialSkeletonEntries[i].KineDriverResourceHandle : null;
if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, (uint)i) is { } sklbNode) if (context.CreateNodeFromPartialSkeleton(&skeleton->PartialSkeletons[i], phybHandle, kdbHandle, (uint)i) is { } sklbNode)
{ {
if (context.Global.WithUiData) if (context.Global.WithUiData)
sklbNode.FallbackName = $"{prefix}Skeleton #{i}"; sklbNode.FallbackName = $"{prefix}Skeleton #{i}";

View file

@ -421,9 +421,9 @@ public sealed unsafe partial class RedrawService : IDisposable
return; return;
foreach (ref var f in currentTerritory->Furniture) foreach (ref var f in currentTerritory->FurnitureManager.FurnitureMemory)
{ {
var gameObject = f.Index >= 0 ? currentTerritory->HousingObjectManager.Objects[f.Index].Value : null; var gameObject = f.Index >= 0 ? currentTerritory->FurnitureManager.ObjectManager.ObjectArray.Objects[f.Index].Value : null;
if (gameObject == null) if (gameObject == null)
continue; continue;

View file

@ -1,3 +1,4 @@
using Dalamud.Bindings.ImGui;
using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel; using FFXIVClientStructs.FFXIV.Client.Graphics.Kernel;
using OtterGui.Services; using OtterGui.Services;
using SharpDX.Direct3D; using SharpDX.Direct3D;
@ -16,7 +17,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable
private readonly HashSet<(nint XivTexture, byte SliceIndex)> _expiredKeys = []; private readonly HashSet<(nint XivTexture, byte SliceIndex)> _expiredKeys = [];
/// <remarks> Caching this across frames will cause a crash to desktop. </remarks> /// <remarks> Caching this across frames will cause a crash to desktop. </remarks>
public nint GetImGuiHandle(Texture* texture, byte sliceIndex) public ImTextureID GetImGuiHandle(Texture* texture, byte sliceIndex)
{ {
if (texture == null) if (texture == null)
throw new ArgumentNullException(nameof(texture)); throw new ArgumentNullException(nameof(texture));
@ -25,7 +26,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable
if (_activeSlices.TryGetValue(((nint)texture, sliceIndex), out var state)) if (_activeSlices.TryGetValue(((nint)texture, sliceIndex), out var state))
{ {
state.Refresh(); state.Refresh();
return (nint)state.ShaderResourceView; return new ImTextureID((nint)state.ShaderResourceView);
} }
var srv = (ShaderResourceView)(nint)texture->D3D11ShaderResourceView; var srv = (ShaderResourceView)(nint)texture->D3D11ShaderResourceView;
var description = srv.Description; var description = srv.Description;
@ -60,7 +61,7 @@ public sealed unsafe class TextureArraySlicer : IUiService, IDisposable
} }
state = new SliceState(new ShaderResourceView(srv.Device, srv.Resource, description)); state = new SliceState(new ShaderResourceView(srv.Device, srv.Resource, description));
_activeSlices.Add(((nint)texture, sliceIndex), state); _activeSlices.Add(((nint)texture, sliceIndex), state);
return (nint)state.ShaderResourceView; return new ImTextureID((nint)state.ShaderResourceView);
} }
public void Tick() public void Tick()

View file

@ -10,28 +10,34 @@ internal static class StructExtensions
public static CiByteString AsByteString(in this StdString str) public static CiByteString AsByteString(in this StdString str)
=> CiByteString.FromSpanUnsafe(str.AsSpan(), true); => CiByteString.FromSpanUnsafe(str.AsSpan(), true);
public static CiByteString ResolveEidPathAsByteString(ref this CharacterBase character) public static CiByteString ResolveEidPathAsByteString(ref this CharacterBase character)
{ {
Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveEidPath(pathBuffer)); return ToOwnedByteString(character.ResolveEidPath(pathBuffer));
}
public static CiByteString ResolveImcPathAsByteString(ref this CharacterBase character, uint slotIndex)
{
Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveImcPath(pathBuffer, slotIndex));
} }
public static CiByteString ResolveMdlPathAsByteString(ref this CharacterBase character, uint slotIndex) public static CiByteString ResolveImcPathAsByteString(ref this CharacterBase character, uint slotIndex)
{ {
Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveMdlPath(pathBuffer, slotIndex)); return ToOwnedByteString(character.ResolveImcPath(pathBuffer, slotIndex));
} }
public static unsafe CiByteString ResolveMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex, byte* mtrlFileName) public static CiByteString ResolveMdlPathAsByteString(ref this CharacterBase character, uint slotIndex)
{ {
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex, mtrlFileName)); return ToOwnedByteString(character.ResolveMdlPath(pathBuffer, slotIndex));
}
public static unsafe CiByteString ResolveMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex, byte* mtrlFileName)
{
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex, mtrlFileName));
}
public static unsafe CiByteString ResolveSkinMtrlPathAsByteString(ref this CharacterBase character, uint slotIndex)
{
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveSkinMtrlPath(pathBuffer, CharacterBase.PathBufferSize, slotIndex));
} }
public static CiByteString ResolveMaterialPapPathAsByteString(ref this CharacterBase character, uint slotIndex, uint unkSId) public static CiByteString ResolveMaterialPapPathAsByteString(ref this CharacterBase character, uint slotIndex, uint unkSId)
@ -40,16 +46,16 @@ internal static class StructExtensions
return ToOwnedByteString(character.ResolveMaterialPapPath(pathBuffer, slotIndex, unkSId)); return ToOwnedByteString(character.ResolveMaterialPapPath(pathBuffer, slotIndex, unkSId));
} }
public static CiByteString ResolveSklbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) public static CiByteString ResolveSklbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{ {
Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveSklbPath(pathBuffer, partialSkeletonIndex)); return ToOwnedByteString(character.ResolveSklbPath(pathBuffer, partialSkeletonIndex));
} }
public static CiByteString ResolveSkpPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) public static CiByteString ResolveSkpPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{ {
Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize]; Span<byte> pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveSkpPath(pathBuffer, partialSkeletonIndex)); return ToOwnedByteString(character.ResolveSkpPath(pathBuffer, partialSkeletonIndex));
} }
public static CiByteString ResolvePhybPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex) public static CiByteString ResolvePhybPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
@ -58,6 +64,12 @@ internal static class StructExtensions
return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex)); return ToOwnedByteString(character.ResolvePhybPath(pathBuffer, partialSkeletonIndex));
} }
public static unsafe CiByteString ResolveKdbPathAsByteString(ref this CharacterBase character, uint partialSkeletonIndex)
{
var pathBuffer = stackalloc byte[CharacterBase.PathBufferSize];
return ToOwnedByteString(character.ResolveKdbPath(pathBuffer, CharacterBase.PathBufferSize, partialSkeletonIndex));
}
private static unsafe CiByteString ToOwnedByteString(CStringPointer str) private static unsafe CiByteString ToOwnedByteString(CStringPointer str)
=> str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty; => str.HasValue ? new CiByteString(str.Value).Clone() : CiByteString.Empty;

View file

@ -1,7 +1,10 @@
using System.Collections.Frozen;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Penumbra.Collections.Cache; using Penumbra.Collections.Cache;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files.AtchStructs; using Penumbra.GameData.Files.AtchStructs;
using Penumbra.GameData.Interop;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Util; using Penumbra.Util;
using ImcEntry = Penumbra.GameData.Structs.ImcEntry; using ImcEntry = Penumbra.GameData.Structs.ImcEntry;
@ -40,6 +43,165 @@ public class MetaDictionary
foreach (var geqp in cache.GlobalEqp.Keys) foreach (var geqp in cache.GlobalEqp.Keys)
Add(geqp); Add(geqp);
} }
public static unsafe Wrapper Filtered(MetaCache cache, Actor actor)
{
if (!actor.IsCharacter)
return new Wrapper(cache);
var model = actor.Model;
if (!model.IsHuman)
return new Wrapper(cache);
var headId = model.GetModelId(HumanSlot.Head);
var bodyId = model.GetModelId(HumanSlot.Body);
var equipIdSet = ((IEnumerable<PrimaryId>)
[
headId,
bodyId,
model.GetModelId(HumanSlot.Hands),
model.GetModelId(HumanSlot.Legs),
model.GetModelId(HumanSlot.Feet),
]).ToFrozenSet();
var earsId = model.GetModelId(HumanSlot.Ears);
var neckId = model.GetModelId(HumanSlot.Neck);
var wristId = model.GetModelId(HumanSlot.Wrists);
var rFingerId = model.GetModelId(HumanSlot.RFinger);
var lFingerId = model.GetModelId(HumanSlot.LFinger);
var wrapper = new Wrapper();
// Check for all relevant primary IDs due to slot overlap.
foreach (var (eqp, value) in cache.Eqp)
{
if (eqp.Slot.IsEquipment())
{
if (equipIdSet.Contains(eqp.SetId))
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
}
else
{
switch (eqp.Slot)
{
case EquipSlot.Ears when eqp.SetId == earsId:
case EquipSlot.Neck when eqp.SetId == neckId:
case EquipSlot.Wrists when eqp.SetId == wristId:
case EquipSlot.RFinger when eqp.SetId == rFingerId:
case EquipSlot.LFinger when eqp.SetId == lFingerId:
wrapper.Eqp.Add(eqp, new EqpEntryInternal(value.Entry, eqp.Slot));
break;
}
}
}
// Check also for body IDs due to body occupying head.
foreach (var (gmp, value) in cache.Gmp)
{
if (gmp.SetId == headId || gmp.SetId == bodyId)
wrapper.Gmp.Add(gmp, value.Entry);
}
// Check for all races due to inheritance and all slots due to overlap.
foreach (var (eqdp, value) in cache.Eqdp)
{
if (eqdp.Slot.IsEquipment())
{
if (equipIdSet.Contains(eqdp.SetId))
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
}
else
{
switch (eqdp.Slot)
{
case EquipSlot.Ears when eqdp.SetId == earsId:
case EquipSlot.Neck when eqdp.SetId == neckId:
case EquipSlot.Wrists when eqdp.SetId == wristId:
case EquipSlot.RFinger when eqdp.SetId == rFingerId:
case EquipSlot.LFinger when eqdp.SetId == lFingerId:
wrapper.Eqdp.Add(eqdp, new EqdpEntryInternal(value.Entry, eqdp.Slot));
break;
}
}
}
var genderRace = (GenderRace)model.AsHuman->RaceSexId;
var hairId = model.GetModelId(HumanSlot.Hair);
var faceId = model.GetModelId(HumanSlot.Face);
// We do not need to care for racial inheritance for ESTs.
foreach (var (est, value) in cache.Est)
{
switch (est.Slot)
{
case EstType.Hair when est.SetId == hairId && est.GenderRace == genderRace:
case EstType.Face when est.SetId == faceId && est.GenderRace == genderRace:
case EstType.Body when est.SetId == bodyId && est.GenderRace == genderRace:
case EstType.Head when (est.SetId == headId || est.SetId == bodyId) && est.GenderRace == genderRace:
wrapper.Est.Add(est, value.Entry);
break;
}
}
foreach (var (geqp, _) in cache.GlobalEqp)
{
switch (geqp.Type)
{
case GlobalEqpType.DoNotHideEarrings when geqp.Condition != earsId:
case GlobalEqpType.DoNotHideNecklace when geqp.Condition != neckId:
case GlobalEqpType.DoNotHideBracelets when geqp.Condition != wristId:
case GlobalEqpType.DoNotHideRingR when geqp.Condition != rFingerId:
case GlobalEqpType.DoNotHideRingL when geqp.Condition != lFingerId:
continue;
default: wrapper.Add(geqp); break;
}
}
var (_, _, main, off) = model.GetWeapons(actor);
foreach (var (imc, value) in cache.Imc)
{
switch (imc.ObjectType)
{
case ObjectType.Equipment when equipIdSet.Contains(imc.PrimaryId): wrapper.Imc.Add(imc, value.Entry); break;
case ObjectType.Weapon:
if (imc.PrimaryId == main.Skeleton && imc.SecondaryId == main.Weapon)
wrapper.Imc.Add(imc, value.Entry);
else if (imc.PrimaryId == off.Skeleton && imc.SecondaryId == off.Weapon)
wrapper.Imc.Add(imc, value.Entry);
break;
case ObjectType.Accessory:
switch (imc.EquipSlot)
{
case EquipSlot.Ears when imc.PrimaryId == earsId:
case EquipSlot.Neck when imc.PrimaryId == neckId:
case EquipSlot.Wrists when imc.PrimaryId == wristId:
case EquipSlot.RFinger when imc.PrimaryId == rFingerId:
case EquipSlot.LFinger when imc.PrimaryId == lFingerId:
wrapper.Imc.Add(imc, value.Entry);
break;
}
break;
}
}
var subRace = (SubRace)model.AsHuman->Customize[4];
foreach (var (rsp, value) in cache.Rsp)
{
if (rsp.SubRace == subRace)
wrapper.Rsp.Add(rsp, value.Entry);
}
// Keep all atch, atr and shp.
wrapper.Atch.EnsureCapacity(cache.Atch.Count);
wrapper.Shp.EnsureCapacity(cache.Shp.Count);
wrapper.Atr.EnsureCapacity(cache.Atr.Count);
foreach (var (atch, value) in cache.Atch)
wrapper.Atch.Add(atch, value.Entry);
foreach (var (shp, value) in cache.Shp)
wrapper.Shp.Add(shp, value.Entry);
foreach (var (atr, value) in cache.Atr)
wrapper.Atr.Add(atr, value.Entry);
return wrapper;
}
} }
private Wrapper? _data; private Wrapper? _data;
@ -126,6 +288,7 @@ public class MetaDictionary
{ {
_data = null; _data = null;
Count = 0; Count = 0;
return;
} }
Count = GlobalEqp.Count + Shp.Count + Atr.Count; Count = GlobalEqp.Count + Shp.Count + Atr.Count;
@ -933,4 +1096,24 @@ public class MetaDictionary
_data = new Wrapper(cache); _data = new Wrapper(cache);
Count = cache.Count; Count = cache.Count;
} }
public MetaDictionary(MetaCache? cache, Actor actor)
{
if (cache is null)
return;
_data = Wrapper.Filtered(cache, actor);
Count = _data.Count
+ _data.Eqp.Count
+ _data.Eqdp.Count
+ _data.Est.Count
+ _data.Gmp.Count
+ _data.Imc.Count
+ _data.Rsp.Count
+ _data.Atch.Count
+ _data.Atr.Count
+ _data.Shp.Count;
if (Count is 0)
_data = null;
}
} }

View file

@ -58,11 +58,72 @@ public unsafe class ShapeAttributeManager : IRequiredService, IDisposable
_ids[(int)_modelIndex] = model.GetModelId(_modelIndex); _ids[(int)_modelIndex] = model.GetModelId(_modelIndex);
CheckShapes(collection.MetaCache!.Shp); CheckShapes(collection.MetaCache!.Shp);
CheckAttributes(collection.MetaCache!.Atr); CheckAttributes(collection.MetaCache!.Atr);
if (_modelIndex is <= HumanSlot.LFinger and >= HumanSlot.Ears)
AccessoryImcCheck(model);
} }
UpdateDefaultMasks(model, collection.MetaCache!.Shp); UpdateDefaultMasks(model, collection.MetaCache!.Shp);
} }
private void AccessoryImcCheck(Model model)
{
var imcMask = (ushort)(0x03FF & *(ushort*)(model.Address + 0xAAC + 6 * (int)_modelIndex));
Span<byte> attr =
[
(byte)'a',
(byte)'t',
(byte)'r',
(byte)'_',
AccessoryByte(_modelIndex),
(byte)'v',
(byte)'_',
(byte)'a',
0,
];
for (var i = 1; i < 10; ++i)
{
var flag = (ushort)(1 << i);
if ((imcMask & flag) is not 0)
continue;
attr[^2] = (byte)('a' + i);
foreach (var (attribute, index) in _model->ModelResourceHandle->Attributes)
{
if (!EqualAttribute(attr, attribute.Value))
continue;
_model->EnabledAttributeIndexMask &= ~(1u << index);
break;
}
}
}
[MethodImpl(MethodImplOptions.AggressiveOptimization | MethodImplOptions.AggressiveInlining)]
private static bool EqualAttribute(Span<byte> needle, byte* haystack)
{
foreach (var character in needle)
{
if (*haystack++ != character)
return false;
}
return true;
}
private static byte AccessoryByte(HumanSlot slot)
=> slot switch
{
HumanSlot.Head => (byte)'m',
HumanSlot.Ears => (byte)'e',
HumanSlot.Neck => (byte)'n',
HumanSlot.Wrists => (byte)'w',
HumanSlot.RFinger => (byte)'r',
HumanSlot.LFinger => (byte)'r',
_ => 0,
};
private void CheckAttributes(AtrCache attributeCache) private void CheckAttributes(AtrCache attributeCache)
{ {
if (attributeCache.DisabledCount is 0) if (attributeCache.DisabledCount is 0)

View file

@ -6,6 +6,7 @@ using OtterGui.Extensions;
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Api.Enums; using Penumbra.Api.Enums;
using Penumbra.Communication; using Penumbra.Communication;
using Penumbra.Mods.Groups;
using Penumbra.Mods.Manager; using Penumbra.Mods.Manager;
using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.Manager.OptionEditor;
using Penumbra.Mods.SubMods; using Penumbra.Mods.SubMods;
@ -44,13 +45,13 @@ public class ModMerger : IDisposable, IService
public ModMerger(ModManager mods, ModGroupEditor editor, ModSelection selection, DuplicateManager duplicates, public ModMerger(ModManager mods, ModGroupEditor editor, ModSelection selection, DuplicateManager duplicates,
CommunicatorService communicator, ModCreator creator, Configuration config) CommunicatorService communicator, ModCreator creator, Configuration config)
{ {
_editor = editor; _editor = editor;
_selection = selection; _selection = selection;
_duplicates = duplicates; _duplicates = duplicates;
_communicator = communicator; _communicator = communicator;
_creator = creator; _creator = creator;
_config = config; _config = config;
_mods = mods; _mods = mods;
_selection.Subscribe(OnSelectionChange, ModSelection.Priority.ModMerger); _selection.Subscribe(OnSelectionChange, ModSelection.Priority.ModMerger);
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModMerger); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModMerger);
} }
@ -99,26 +100,117 @@ public class ModMerger : IDisposable, IService
foreach (var originalGroup in MergeFromMod!.Groups) foreach (var originalGroup in MergeFromMod!.Groups)
{ {
var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name); switch (originalGroup.Type)
if (groupCreated)
_createdGroups.Add(groupIdx);
if (group == null)
throw new Exception(
$"The merged group {originalGroup.Name} already existed, but had a different type than the original group of type {originalGroup.Type}.");
foreach (var originalOption in originalGroup.DataContainers)
{ {
var (option, _, optionCreated) = _editor.FindOrAddOption(group, originalOption.GetName()); case GroupType.Single:
if (optionCreated) case GroupType.Multi:
{ {
_createdOptions.Add(option!); var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, originalGroup.Type, originalGroup.Name);
// #TODO DataContainer <> Option. if (group is null)
MergeIntoOption([originalOption], (IModDataContainer)option!, false); throw new Exception(
$"The merged group {originalGroup.Name} already existed, but had a different type than the original group of type {originalGroup.Type}.");
if (groupCreated)
{
_createdGroups.Add(groupIdx);
group.Description = originalGroup.Description;
group.Image = originalGroup.Image;
group.DefaultSettings = originalGroup.DefaultSettings;
group.Page = originalGroup.Page;
group.Priority = originalGroup.Priority;
}
foreach (var originalOption in originalGroup.Options)
{
var (option, _, optionCreated) = _editor.FindOrAddOption(group, originalOption.Name);
if (optionCreated)
{
_createdOptions.Add(option!);
MergeIntoOption([(IModDataContainer)originalOption], (IModDataContainer)option!, false);
option!.Description = originalOption.Description;
if (option is MultiSubMod multiOption)
multiOption.Priority = ((MultiSubMod)originalOption).Priority;
}
else
{
throw new Exception(
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option!.FullName} already existed.");
}
}
break;
} }
else
case GroupType.Imc when originalGroup is ImcModGroup imc:
{ {
throw new Exception( var group = _editor.ImcEditor.AddModGroup(MergeToMod!, imc.Name, imc.Identifier, imc.DefaultEntry);
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: The option {option!.FullName} already existed."); if (group is null)
throw new Exception(
$"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged.");
group.AllVariants = imc.AllVariants;
group.OnlyAttributes = imc.OnlyAttributes;
group.Description = imc.Description;
group.Image = imc.Image;
group.DefaultSettings = imc.DefaultSettings;
group.Page = imc.Page;
group.Priority = imc.Priority;
foreach (var originalOption in imc.OptionData)
{
if (originalOption.IsDisableSubMod)
{
_editor.ImcEditor.ChangeCanBeDisabled(group, true);
var disable = group.OptionData.First(s => s.IsDisableSubMod);
disable.Description = originalOption.Description;
disable.Name = originalOption.Name;
continue;
}
var newOption = _editor.ImcEditor.AddOption(group, originalOption.Name);
if (newOption is null)
throw new Exception(
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating IMC option {originalOption.FullName}.");
newOption.Description = originalOption.Description;
newOption.AttributeMask = originalOption.AttributeMask;
}
break;
}
case GroupType.Combining when originalGroup is CombiningModGroup combining:
{
var group = _editor.CombiningEditor.AddModGroup(MergeToMod!, combining.Name);
if (group is null)
throw new Exception(
$"The merged group {originalGroup.Name} already existed, but groups of type {originalGroup.Type} can not be merged.");
group.Description = combining.Description;
group.Image = combining.Image;
group.DefaultSettings = combining.DefaultSettings;
group.Page = combining.Page;
group.Priority = combining.Priority;
foreach (var originalOption in combining.OptionData)
{
var option = _editor.CombiningEditor.AddOption(group, originalOption.Name);
if (option is null)
throw new Exception(
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error when creating combining option {originalOption.FullName}.");
option.Description = originalOption.Description;
}
if (group.Data.Count != combining.Data.Count)
throw new Exception(
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}: Unknown error caused data container counts in combining group {originalGroup.Name} to differ.");
foreach (var (originalContainer, container) in combining.Data.Zip(group.Data))
{
container.Name = originalContainer.Name;
MergeIntoOption([originalContainer], container, false);
}
break;
} }
} }
} }
@ -151,7 +243,6 @@ public class ModMerger : IDisposable, IService
if (!dir.Exists) if (!dir.Exists)
_createdDirectories.Add(dir.FullName); _createdDirectories.Add(dir.FullName);
CopyFiles(dir); CopyFiles(dir);
// #TODO DataContainer <> Option.
MergeIntoOption(MergeFromMod!.AllDataContainers.Reverse(), (IModDataContainer)option!, true); MergeIntoOption(MergeFromMod!.AllDataContainers.Reverse(), (IModDataContainer)option!, true);
} }
@ -281,7 +372,6 @@ public class ModMerger : IDisposable, IService
} }
else else
{ {
// TODO DataContainer <> Option.
var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name); var (group, _, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name);
var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName()); var (option, _, _) = _editor.FindOrAddOption(group!, originalOption.GetName());
var folder = Path.Combine(dir.FullName, group!.Name, option!.Name); var folder = Path.Combine(dir.FullName, group!.Name, option!.Name);

View file

@ -76,7 +76,7 @@ public class ModNormalizer(ModManager modManager, Configuration config, SaveServ
else else
{ {
var groupDir = ModCreator.NewOptionDirectory(mod.ModPath, container.Group.Name, config.ReplaceNonAsciiOnImport); var groupDir = ModCreator.NewOptionDirectory(mod.ModPath, container.Group.Name, config.ReplaceNonAsciiOnImport);
var optionDir = ModCreator.NewOptionDirectory(groupDir, container.GetName(), config.ReplaceNonAsciiOnImport); var optionDir = ModCreator.NewOptionDirectory(groupDir, container.GetDirectoryName(), config.ReplaceNonAsciiOnImport);
containers[container] = optionDir.FullName; containers[container] = optionDir.FullName;
} }
} }
@ -286,7 +286,7 @@ public class ModNormalizer(ModManager modManager, Configuration config, SaveServ
void HandleSubMod(DirectoryInfo groupDir, IModDataContainer option, Dictionary<Utf8GamePath, FullPath> newDict) void HandleSubMod(DirectoryInfo groupDir, IModDataContainer option, Dictionary<Utf8GamePath, FullPath> newDict)
{ {
var name = option.GetName(); var name = option.GetDirectoryName();
var optionDir = ModCreator.CreateModFolder(groupDir, name, config.ReplaceNonAsciiOnImport, true); var optionDir = ModCreator.CreateModFolder(groupDir, name, config.ReplaceNonAsciiOnImport, true);
newDict.Clear(); newDict.Clear();

View file

@ -0,0 +1,95 @@
using System.Collections.Frozen;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Bindings.ImGui;
using OtterGui.Text;
using Penumbra.Mods.Manager;
using Penumbra.UI.Classes;
using Notification = OtterGui.Classes.Notification;
namespace Penumbra.Mods;
public static class FeatureChecker
{
/// <summary> Manually setup supported features to exclude None and Invalid and not make something supported too early. </summary>
private static readonly FrozenDictionary<string, FeatureFlags> SupportedFlags = new[]
{
FeatureFlags.Atch,
FeatureFlags.Shp,
FeatureFlags.Atr,
}.ToFrozenDictionary(f => f.ToString(), f => f);
public static IReadOnlyCollection<string> SupportedFeatures
=> SupportedFlags.Keys;
public static FeatureFlags ParseFlags(string modDirectory, string modName, IEnumerable<string> features)
{
var featureFlags = FeatureFlags.None;
HashSet<string> missingFeatures = [];
foreach (var feature in features)
{
if (SupportedFlags.TryGetValue(feature, out var featureFlag))
featureFlags |= featureFlag;
else
missingFeatures.Add(feature);
}
if (missingFeatures.Count > 0)
{
Penumbra.Messager.AddMessage(new Notification(
$"Please update Penumbra to use the mod {modName}{(modDirectory != modName ? $" at {modDirectory}" : string.Empty)}!\n\nLoading failed because it requires the unsupported feature{(missingFeatures.Count > 1 ? $"s\n\n\t[{string.Join("], [", missingFeatures)}]." : $" [{missingFeatures.First()}].")}",
NotificationType.Warning));
return FeatureFlags.Invalid;
}
return featureFlags;
}
public static bool Supported(string features)
=> SupportedFlags.ContainsKey(features);
public static void DrawFeatureFlagInput(ModDataEditor editor, Mod mod, float width)
{
const int numButtons = 5;
var innerSpacing = ImGui.GetStyle().ItemInnerSpacing;
var size = new Vector2((width - (numButtons - 1) * innerSpacing.X) / numButtons, 0);
var buttonColor = ImGui.GetColorU32(ImGuiCol.FrameBg);
var textColor = ImGui.GetColorU32(ImGuiCol.TextDisabled);
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, innerSpacing)
.Push(ImGuiStyleVar.FrameBorderSize, 0);
using (var color = ImRaii.PushColor(ImGuiCol.Border, ColorId.FolderLine.Value())
.Push(ImGuiCol.Button, buttonColor)
.Push(ImGuiCol.Text, textColor))
{
foreach (var flag in SupportedFlags.Values)
{
if (mod.RequiredFeatures.HasFlag(flag))
{
style.Push(ImGuiStyleVar.FrameBorderSize, ImUtf8.GlobalScale);
color.Pop(2);
if (ImUtf8.Button($"{flag}", size))
editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures & ~flag);
color.Push(ImGuiCol.Button, buttonColor)
.Push(ImGuiCol.Text, textColor);
style.Pop();
}
else if (ImUtf8.Button($"{flag}", size))
{
editor.ChangeRequiredFeatures(mod, mod.RequiredFeatures | flag);
}
ImGui.SameLine();
}
}
if (ImUtf8.ButtonEx("Compute"u8, "Compute the required features automatically from the used features."u8, size))
editor.ChangeRequiredFeatures(mod, mod.ComputeRequiredFeatures());
ImGui.SameLine();
if (ImUtf8.ButtonEx("Clear"u8, "Clear all required features."u8, size))
editor.ChangeRequiredFeatures(mod, FeatureFlags.None);
ImGui.SameLine();
ImUtf8.Text("Required Features"u8);
}
}

View file

@ -0,0 +1,180 @@
using Dalamud.Interface.ImGuiNotification;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
using OtterGui.Extensions;
using Penumbra.Api.Enums;
using Penumbra.GameData.Data;
using Penumbra.Meta.Manipulations;
using Penumbra.Mods.Settings;
using Penumbra.Mods.SubMods;
using Penumbra.String.Classes;
using Penumbra.UI.ModsTab.Groups;
using Penumbra.Util;
namespace Penumbra.Mods.Groups;
public sealed class ComplexModGroup(Mod mod) : IModGroup
{
public Mod Mod { get; } = mod;
public string Name { get; set; } = "Option";
public string Description { get; set; } = string.Empty;
public string Image { get; set; } = string.Empty;
public GroupType Type
=> GroupType.Complex;
public GroupDrawBehaviour Behaviour
=> GroupDrawBehaviour.Complex;
public ModPriority Priority { get; set; }
public int Page { get; set; }
public Setting DefaultSettings { get; set; }
public readonly List<ComplexSubMod> Options = [];
public readonly List<ComplexDataContainer> Containers = [];
public FullPath? FindBestMatch(Utf8GamePath gamePath)
=> throw new NotImplementedException();
public IModOption? AddOption(string name, string description = "")
=> throw new NotImplementedException();
IReadOnlyList<IModOption> IModGroup.Options
=> Options;
IReadOnlyList<IModDataContainer> IModGroup.DataContainers
=> Containers;
public bool IsOption
=> Options.Count > 0;
public int GetIndex()
=> ModGroup.GetIndex(this);
public IModGroupEditDrawer EditDrawer(ModGroupEditDrawer editDrawer)
=> throw new NotImplementedException();
public void AddData(Setting setting, Dictionary<Utf8GamePath, FullPath> redirections, MetaDictionary manipulations)
{
foreach (var container in Containers.Where(c => c.Association.IsEnabled(setting)))
SubMod.AddContainerTo(container, redirections, manipulations);
}
public void AddChangedItems(ObjectIdentification identifier, IDictionary<string, IIdentifiedObjectData> changedItems)
{
foreach (var container in Containers)
identifier.AddChangedItems(container, changedItems);
}
public Setting FixSetting(Setting setting)
=> new(setting.Value & ((1ul << Options.Count) - 1));
public void WriteJson(JsonTextWriter jWriter, JsonSerializer serializer, DirectoryInfo? basePath = null)
{
ModSaveGroup.WriteJsonBase(jWriter, this);
jWriter.WritePropertyName("Options");
jWriter.WriteStartArray();
foreach (var option in Options)
{
jWriter.WriteStartObject();
SubMod.WriteModOption(jWriter, option);
if (!option.Conditions.IsZero)
{
jWriter.WritePropertyName("ConditionMask");
jWriter.WriteValue(option.Conditions.Mask.Value);
jWriter.WritePropertyName("ConditionValue");
jWriter.WriteValue(option.Conditions.Value.Value);
}
if (option.Indentation > 0)
{
jWriter.WritePropertyName("Indentation");
jWriter.WriteValue(option.Indentation);
}
if (option.SubGroupLabel.Length > 0)
{
jWriter.WritePropertyName("SubGroup");
jWriter.WriteValue(option.SubGroupLabel);
}
jWriter.WriteEndObject();
}
jWriter.WriteEndArray();
jWriter.WritePropertyName("Containers");
jWriter.WriteStartArray();
foreach (var container in Containers)
{
jWriter.WriteStartObject();
if (container.Name.Length > 0)
{
jWriter.WritePropertyName("Name");
jWriter.WriteValue(container.Name);
}
if (!container.Association.IsZero)
{
jWriter.WritePropertyName("AssociationMask");
jWriter.WriteValue(container.Association.Mask.Value);
jWriter.WritePropertyName("AssociationValue");
jWriter.WriteValue(container.Association.Value.Value);
}
SubMod.WriteModContainer(jWriter, serializer, container, basePath ?? Mod.ModPath);
jWriter.WriteEndObject();
}
jWriter.WriteEndArray();
}
public (int Redirections, int Swaps, int Manips) GetCounts()
=> ModGroup.GetCountsBase(this);
public static ComplexModGroup? Load(Mod mod, JObject json)
{
var ret = new ComplexModGroup(mod);
if (!ModSaveGroup.ReadJsonBase(json, ret))
return null;
var options = json["Options"];
if (options != null)
foreach (var child in options.Children())
{
if (ret.Options.Count == IModGroup.MaxComplexOptions)
{
Penumbra.Messager.NotificationMessage(
$"Complex Group {ret.Name} in {mod.Name} has more than {IModGroup.MaxComplexOptions} options, ignoring excessive options.",
NotificationType.Warning);
break;
}
var subMod = new ComplexSubMod(ret, child);
ret.Options.Add(subMod);
}
// Fix up conditions: No condition on itself.
foreach (var (option, index) in ret.Options.WithIndex())
{
option.Conditions = option.Conditions.Limit(ret.Options.Count);
option.Conditions = new MaskedSetting(option.Conditions.Mask.SetBit(index, false), option.Conditions.Value);
}
var containers = json["Containers"];
if (containers != null)
foreach (var child in containers.Children())
{
var container = new ComplexDataContainer(ret, child);
container.Association = container.Association.Limit(ret.Options.Count);
ret.Containers.Add(container);
}
ret.DefaultSettings = ret.FixSetting(ret.DefaultSettings);
return ret;
}
}

View file

@ -18,11 +18,13 @@ public enum GroupDrawBehaviour
{ {
SingleSelection, SingleSelection,
MultiSelection, MultiSelection,
Complex,
} }
public interface IModGroup public interface IModGroup
{ {
public const int MaxMultiOptions = 32; public const int MaxMultiOptions = 32;
public const int MaxComplexOptions = MaxMultiOptions;
public const int MaxCombiningOptions = 8; public const int MaxCombiningOptions = 8;
public Mod Mod { get; } public Mod Mod { get; }

View file

@ -234,9 +234,56 @@ public static class EquipmentSwap
mdl.ChildSwaps.Add(mtrl); mdl.ChildSwaps.Add(mtrl);
} }
FixAttributes(mdl, slotFrom, slotTo);
return mdl; return mdl;
} }
private static void FixAttributes(FileSwap swap, EquipSlot slotFrom, EquipSlot slotTo)
{
if (slotFrom == slotTo)
return;
var needle = slotTo switch
{
EquipSlot.Head => "atr_mv_",
EquipSlot.Ears => "atr_ev_",
EquipSlot.Neck => "atr_nv_",
EquipSlot.Wrists => "atr_wv_",
EquipSlot.RFinger or EquipSlot.LFinger => "atr_rv_",
_ => string.Empty,
};
var replacement = slotFrom switch
{
EquipSlot.Head => 'm',
EquipSlot.Ears => 'e',
EquipSlot.Neck => 'n',
EquipSlot.Wrists => 'w',
EquipSlot.RFinger or EquipSlot.LFinger => 'r',
_ => 'm',
};
var attributes = swap.AsMdl()!.Attributes;
for (var i = 0; i < attributes.Length; ++i)
{
if (FixAttribute(ref attributes[i], needle, replacement))
swap.DataWasChanged = true;
}
}
private static unsafe bool FixAttribute(ref string attribute, string from, char to)
{
if (!attribute.StartsWith(from) || attribute.Length != from.Length + 1 || attribute[^1] is < 'a' or > 'j')
return false;
Span<char> stack = stackalloc char[attribute.Length];
attribute.CopyTo(stack);
stack[4] = to;
attribute = new string(stack);
return true;
}
private static void LookupItem(EquipItem i, out EquipSlot slot, out PrimaryId modelId, out Variant variant) private static void LookupItem(EquipItem i, out EquipSlot slot, out PrimaryId modelId, out Variant variant)
{ {
slot = i.Type.ToSlot(); slot = i.Type.ToSlot();
@ -399,7 +446,7 @@ public static class EquipmentSwap
return null; return null;
var folderTo = GamePaths.Mtrl.GearFolder(slotTo, idTo, variantTo); var folderTo = GamePaths.Mtrl.GearFolder(slotTo, idTo, variantTo);
var pathTo = $"{folderTo}{fileName}"; var pathTo = $"{folderTo}{fileName}";
var folderFrom = GamePaths.Mtrl.GearFolder(slotFrom, idFrom, variantTo); var folderFrom = GamePaths.Mtrl.GearFolder(slotFrom, idFrom, variantTo);
var newFileName = ItemSwap.ReplaceId(fileName, prefix, idTo, idFrom); var newFileName = ItemSwap.ReplaceId(fileName, prefix, idTo, idFrom);

View file

@ -26,6 +26,7 @@ public enum ModDataChangeType : ushort
Image = 0x1000, Image = 0x1000,
DefaultChangedItems = 0x2000, DefaultChangedItems = 0x2000,
PreferredChangedItems = 0x4000, PreferredChangedItems = 0x4000,
RequiredFeatures = 0x8000,
} }
public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService public class ModDataEditor(SaveService saveService, CommunicatorService communicatorService, ItemData itemData) : IService
@ -35,7 +36,7 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic
/// <summary> Create the file containing the meta information about a mod from scratch. </summary> /// <summary> Create the file containing the meta information about a mod from scratch. </summary>
public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version, public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version,
string? website) string? website, params string[] tags)
{ {
var mod = new Mod(directory); var mod = new Mod(directory);
mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name); mod.Name = name.IsNullOrEmpty() ? mod.Name : new LowerString(name);
@ -43,6 +44,7 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic
mod.Description = description ?? mod.Description; mod.Description = description ?? mod.Description;
mod.Version = version ?? mod.Version; mod.Version = version ?? mod.Version;
mod.Website = website ?? mod.Website; mod.Website = website ?? mod.Website;
mod.ModTags = tags;
saveService.ImmediateSaveSync(new ModMeta(mod)); saveService.ImmediateSaveSync(new ModMeta(mod));
} }
@ -95,6 +97,16 @@ public class ModDataEditor(SaveService saveService, CommunicatorService communic
mod.Website = newWebsite; mod.Website = newWebsite;
saveService.QueueSave(new ModMeta(mod)); saveService.QueueSave(new ModMeta(mod));
communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null); communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null);
}
public void ChangeRequiredFeatures(Mod mod, FeatureFlags flags)
{
if (mod.RequiredFeatures == flags)
return;
mod.RequiredFeatures = flags;
saveService.QueueSave(new ModMeta(mod));
communicatorService.ModDataChanged.Invoke(ModDataChangeType.RequiredFeatures, mod, null);
} }
public void ChangeModTag(Mod mod, int tagIdx, string newTag) public void ChangeModTag(Mod mod, int tagIdx, string newTag)

View file

@ -37,11 +37,11 @@ public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable, ISer
public struct ImportDate : ISortMode<Mod> public struct ImportDate : ISortMode<Mod>
{ {
public string Name public ReadOnlySpan<byte> Name
=> "Import Date (Older First)"; => "Import Date (Older First)"u8;
public string Description public ReadOnlySpan<byte> Description
=> "In each folder, sort all subfolders lexicographically, then sort all leaves using their import date."; => "In each folder, sort all subfolders lexicographically, then sort all leaves using their import date."u8;
public IEnumerable<IPath> GetChildren(Folder f) public IEnumerable<IPath> GetChildren(Folder f)
=> f.GetSubFolders().Cast<IPath>().Concat(f.GetLeaves().OrderBy(l => l.Value.ImportDate)); => f.GetSubFolders().Cast<IPath>().Concat(f.GetLeaves().OrderBy(l => l.Value.ImportDate));
@ -49,11 +49,11 @@ public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable, ISer
public struct InverseImportDate : ISortMode<Mod> public struct InverseImportDate : ISortMode<Mod>
{ {
public string Name public ReadOnlySpan<byte> Name
=> "Import Date (Newer First)"; => "Import Date (Newer First)"u8;
public string Description public ReadOnlySpan<byte> Description
=> "In each folder, sort all subfolders lexicographically, then sort all leaves using their inverse import date."; => "In each folder, sort all subfolders lexicographically, then sort all leaves using their inverse import date."u8;
public IEnumerable<IPath> GetChildren(Folder f) public IEnumerable<IPath> GetChildren(Folder f)
=> f.GetSubFolders().Cast<IPath>().Concat(f.GetLeaves().OrderByDescending(l => l.Value.ImportDate)); => f.GetSubFolders().Cast<IPath>().Concat(f.GetLeaves().OrderByDescending(l => l.Value.ImportDate));
@ -80,7 +80,7 @@ public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable, ISer
// Update sort order when defaulted mod names change. // Update sort order when defaulted mod names change.
private void OnModDataChange(ModDataChangeType type, Mod mod, string? oldName) private void OnModDataChange(ModDataChangeType type, Mod mod, string? oldName)
{ {
if (!type.HasFlag(ModDataChangeType.Name) || oldName == null || !FindLeaf(mod, out var leaf)) if (!type.HasFlag(ModDataChangeType.Name) || oldName == null || !TryGetValue(mod, out var leaf))
return; return;
var old = oldName.FixName(); var old = oldName.FixName();
@ -111,7 +111,7 @@ public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable, ISer
CreateDuplicateLeaf(parent, mod.Name.Text, mod); CreateDuplicateLeaf(parent, mod.Name.Text, mod);
break; break;
case ModPathChangeType.Deleted: case ModPathChangeType.Deleted:
if (FindLeaf(mod, out var leaf)) if (TryGetValue(mod, out var leaf))
Delete(leaf); Delete(leaf);
break; break;
@ -124,16 +124,6 @@ public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable, ISer
} }
} }
// Search the entire filesystem for the leaf corresponding to a mod.
public bool FindLeaf(Mod mod, [NotNullWhen(true)] out Leaf? leaf)
{
leaf = Root.GetAllDescendants(ISortMode<Mod>.Lexicographical)
.OfType<Leaf>()
.FirstOrDefault(l => l.Value == mod);
return leaf != null;
}
// Used for saving and loading. // Used for saving and loading.
private static string ModToIdentifier(Mod mod) private static string ModToIdentifier(Mod mod)
=> mod.ModPath.Name; => mod.ModPath.Name;

View file

@ -70,7 +70,6 @@ public class ModImportManager(ModManager modManager, Configuration config, ModEd
_import = null; _import = null;
} }
public bool AddUnpackedMod([NotNullWhen(true)] out Mod? mod) public bool AddUnpackedMod([NotNullWhen(true)] out Mod? mod)
{ {
if (!_modsToAdd.TryDequeue(out var directory)) if (!_modsToAdd.TryDequeue(out var directory))

View file

@ -1,5 +1,6 @@
using OtterGui.Services; using OtterGui.Services;
using Penumbra.Communication; using Penumbra.Communication;
using Penumbra.Interop;
using Penumbra.Mods.Editor; using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.Manager.OptionEditor;
using Penumbra.Services; using Penumbra.Services;
@ -143,9 +144,10 @@ public sealed class ModManager : ModStorage, IDisposable, IService
_communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath); _communicator.ModPathChanged.Invoke(ModPathChangeType.StartingReload, mod, mod.ModPath, mod.ModPath);
if (!Creator.ReloadMod(mod, true, false, out var metaChange)) if (!Creator.ReloadMod(mod, true, false, out var metaChange))
{ {
Penumbra.Log.Warning(mod.Name.Length == 0 if (mod.RequiredFeatures is not FeatureFlags.Invalid)
? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead." Penumbra.Log.Warning(mod.Name.Length == 0
: $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead."); ? $"Reloading mod {oldName} has failed, new name is empty. Removing from loaded mods instead."
: $"Reloading mod {oldName} failed, {mod.ModPath.FullName} does not exist anymore or it has invalid data. Removing from loaded mods instead.");
RemoveMod(mod); RemoveMod(mod);
return; return;
} }
@ -251,12 +253,8 @@ public sealed class ModManager : ModStorage, IDisposable, IService
{ {
switch (type) switch (type)
{ {
case ModPathChangeType.Added: case ModPathChangeType.Added: SetNew(mod); break;
SetNew(mod); case ModPathChangeType.Deleted: SetKnown(mod); break;
break;
case ModPathChangeType.Deleted:
SetKnown(mod);
break;
case ModPathChangeType.Moved: case ModPathChangeType.Moved:
if (oldDirectory != null && newDirectory != null) if (oldDirectory != null && newDirectory != null)
DataEditor.MoveDataFile(oldDirectory, newDirectory); DataEditor.MoveDataFile(oldDirectory, newDirectory);
@ -306,6 +304,9 @@ public sealed class ModManager : ModStorage, IDisposable, IService
if (!firstTime && _config.ModDirectory != BasePath.FullName) if (!firstTime && _config.ModDirectory != BasePath.FullName)
TriggerModDirectoryChange(BasePath.FullName, Valid); TriggerModDirectoryChange(BasePath.FullName, Valid);
} }
if (CloudApi.IsCloudSynced(BasePath.FullName))
Penumbra.Log.Warning($"Mod base directory {BasePath.FullName} is cloud-synced. This may cause issues.");
} }
private void TriggerModDirectoryChange(string newPath, bool valid) private void TriggerModDirectoryChange(string newPath, bool valid)

View file

@ -15,8 +15,8 @@ public abstract class ModOptionEditor<TGroup, TOption>(
where TOption : class, IModOption where TOption : class, IModOption
{ {
protected readonly CommunicatorService Communicator = communicator; protected readonly CommunicatorService Communicator = communicator;
protected readonly SaveService SaveService = saveService; protected readonly SaveService SaveService = saveService;
protected readonly Configuration Config = config; protected readonly Configuration Config = config;
/// <summary> Add a new, empty option group of the given type and name. </summary> /// <summary> Add a new, empty option group of the given type and name. </summary>
public TGroup? AddModGroup(Mod mod, string newName, SaveType saveType = SaveType.ImmediateSync) public TGroup? AddModGroup(Mod mod, string newName, SaveType saveType = SaveType.ImmediateSync)
@ -25,7 +25,7 @@ public abstract class ModOptionEditor<TGroup, TOption>(
return null; return null;
var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1; var maxPriority = mod.Groups.Count == 0 ? ModPriority.Default : mod.Groups.Max(o => o.Priority) + 1;
var group = CreateGroup(mod, newName, maxPriority); var group = CreateGroup(mod, newName, maxPriority);
mod.Groups.Add(group); mod.Groups.Add(group);
SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport)); SaveService.Save(saveType, new ModSaveGroup(group, Config.ReplaceNonAsciiOnImport));
Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, group, null, null, -1); Communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, group, null, null, -1);
@ -92,8 +92,8 @@ public abstract class ModOptionEditor<TGroup, TOption>(
/// <summary> Delete the given option from the given group. </summary> /// <summary> Delete the given option from the given group. </summary>
public void DeleteOption(TOption option) public void DeleteOption(TOption option)
{ {
var mod = option.Mod; var mod = option.Mod;
var group = option.Group; var group = option.Group;
var optionIdx = option.GetIndex(); var optionIdx = option.GetIndex();
Communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, mod, group, option, null, -1); Communicator.ModOptionChanged.Invoke(ModOptionChangeType.PrepareChange, mod, group, option, null, -1);
RemoveOption((TGroup)group, optionIdx); RemoveOption((TGroup)group, optionIdx);
@ -104,7 +104,7 @@ public abstract class ModOptionEditor<TGroup, TOption>(
/// <summary> Move an option inside the given option group. </summary> /// <summary> Move an option inside the given option group. </summary>
public void MoveOption(TOption option, int optionIdxTo) public void MoveOption(TOption option, int optionIdxTo)
{ {
var idx = option.GetIndex(); var idx = option.GetIndex();
var group = (TGroup)option.Group; var group = (TGroup)option.Group;
if (!MoveOption(group, idx, optionIdxTo)) if (!MoveOption(group, idx, optionIdxTo))
return; return;
@ -113,10 +113,10 @@ public abstract class ModOptionEditor<TGroup, TOption>(
Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, idx); Communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionMoved, group.Mod, group, option, null, idx);
} }
protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync); protected abstract TGroup CreateGroup(Mod mod, string newName, ModPriority priority, SaveType saveType = SaveType.ImmediateSync);
protected abstract TOption? CloneOption(TGroup group, IModOption option); protected abstract TOption? CloneOption(TGroup group, IModOption option);
protected abstract void RemoveOption(TGroup group, int optionIndex); protected abstract void RemoveOption(TGroup group, int optionIndex);
protected abstract bool MoveOption(TGroup group, int optionIdxFrom, int optionIdxTo); protected abstract bool MoveOption(TGroup group, int optionIdxFrom, int optionIdxTo);
} }
public static class ModOptionChangeTypeExtension public static class ModOptionChangeTypeExtension
@ -132,22 +132,22 @@ public static class ModOptionChangeTypeExtension
{ {
(requiresSaving, requiresReloading, wasPrepared) = type switch (requiresSaving, requiresReloading, wasPrepared) = type switch
{ {
ModOptionChangeType.GroupRenamed => (true, false, false), ModOptionChangeType.GroupRenamed => (true, false, false),
ModOptionChangeType.GroupAdded => (true, false, false), ModOptionChangeType.GroupAdded => (true, false, false),
ModOptionChangeType.GroupDeleted => (true, true, false), ModOptionChangeType.GroupDeleted => (true, true, false),
ModOptionChangeType.GroupMoved => (true, false, false), ModOptionChangeType.GroupMoved => (true, false, false),
ModOptionChangeType.GroupTypeChanged => (true, true, true), ModOptionChangeType.GroupTypeChanged => (true, true, true),
ModOptionChangeType.PriorityChanged => (true, true, true), ModOptionChangeType.PriorityChanged => (true, true, true),
ModOptionChangeType.OptionAdded => (true, true, true), ModOptionChangeType.OptionAdded => (true, true, true),
ModOptionChangeType.OptionDeleted => (true, true, false), ModOptionChangeType.OptionDeleted => (true, true, false),
ModOptionChangeType.OptionMoved => (true, false, false), ModOptionChangeType.OptionMoved => (true, false, false),
ModOptionChangeType.OptionFilesChanged => (false, true, false), ModOptionChangeType.OptionFilesChanged => (false, true, false),
ModOptionChangeType.OptionFilesAdded => (false, true, true), ModOptionChangeType.OptionFilesAdded => (false, true, true),
ModOptionChangeType.OptionSwapsChanged => (false, true, false), ModOptionChangeType.OptionSwapsChanged => (false, true, false),
ModOptionChangeType.OptionMetaChanged => (false, true, false), ModOptionChangeType.OptionMetaChanged => (false, true, false),
ModOptionChangeType.DisplayChange => (false, false, false), ModOptionChangeType.DisplayChange => (false, false, false),
ModOptionChangeType.DefaultOptionChanged => (true, false, false), ModOptionChangeType.DefaultOptionChanged => (true, false, false),
_ => (false, false, false), _ => (false, false, false),
}; };
} }
} }

View file

@ -1,4 +1,3 @@
using OtterGui;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Extensions; using OtterGui.Extensions;
using Penumbra.GameData.Data; using Penumbra.GameData.Data;
@ -12,6 +11,16 @@ using Penumbra.String.Classes;
namespace Penumbra.Mods; namespace Penumbra.Mods;
[Flags]
public enum FeatureFlags : ulong
{
None = 0,
Atch = 1ul << 0,
Shp = 1ul << 1,
Atr = 1ul << 2,
Invalid = 1ul << 62,
}
public sealed class Mod : IMod public sealed class Mod : IMod
{ {
public static readonly TemporaryMod ForcedFiles = new() public static readonly TemporaryMod ForcedFiles = new()
@ -57,6 +66,7 @@ public sealed class Mod : IMod
public string Image { get; internal set; } = string.Empty; public string Image { get; internal set; } = string.Empty;
public IReadOnlyList<string> ModTags { get; internal set; } = []; public IReadOnlyList<string> ModTags { get; internal set; } = [];
public HashSet<CustomItemId> DefaultPreferredItems { get; internal set; } = []; public HashSet<CustomItemId> DefaultPreferredItems { get; internal set; } = [];
public FeatureFlags RequiredFeatures { get; internal set; } = 0;
// Local Data // Local Data
@ -70,6 +80,23 @@ public sealed class Mod : IMod
public readonly DefaultSubMod Default; public readonly DefaultSubMod Default;
public readonly List<IModGroup> Groups = []; public readonly List<IModGroup> Groups = [];
/// <summary> Compute the required feature flags for this mod. </summary>
public FeatureFlags ComputeRequiredFeatures()
{
var flags = FeatureFlags.None;
foreach (var option in AllDataContainers)
{
if (option.Manipulations.Atch.Count > 0)
flags |= FeatureFlags.Atch;
if (option.Manipulations.Atr.Count > 0)
flags |= FeatureFlags.Atr;
if (option.Manipulations.Shp.Count > 0)
flags |= FeatureFlags.Shp;
}
return flags;
}
public AppliedModData GetData(ModSettings? settings = null) public AppliedModData GetData(ModSettings? settings = null)
{ {
if (settings is not { Enabled: true }) if (settings is not { Enabled: true })

View file

@ -28,15 +28,16 @@ public partial class ModCreator(
MetaFileManager metaFileManager, MetaFileManager metaFileManager,
GamePathParser gamePathParser) : IService GamePathParser gamePathParser) : IService
{ {
public readonly Configuration Config = config; public const FeatureFlags SupportedFeatures = FeatureFlags.Atch | FeatureFlags.Shp | FeatureFlags.Atr;
public readonly Configuration Config = config;
/// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary> /// <summary> Creates directory and files necessary for a new mod without adding it to the manager. </summary>
public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null) public DirectoryInfo? CreateEmptyMod(DirectoryInfo basePath, string newName, string description = "", string? author = null, params string[] tags)
{ {
try try
{ {
var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true); var newDir = CreateModFolder(basePath, newName, Config.ReplaceNonAsciiOnImport, true);
dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty); dataEditor.CreateMeta(newDir, newName, author ?? Config.DefaultModAuthor, description, "1.0", string.Empty, tags);
CreateDefaultFiles(newDir); CreateDefaultFiles(newDir);
return newDir; return newDir;
} }
@ -74,7 +75,7 @@ public partial class ModCreator(
return false; return false;
modDataChange = ModMeta.Load(dataEditor, this, mod); modDataChange = ModMeta.Load(dataEditor, this, mod);
if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0) if (modDataChange.HasFlag(ModDataChangeType.Deletion) || mod.Name.Length == 0 || mod.RequiredFeatures is FeatureFlags.Invalid)
return false; return false;
modDataChange |= ModLocalData.Load(dataEditor, mod); modDataChange |= ModLocalData.Load(dataEditor, mod);
@ -82,9 +83,9 @@ public partial class ModCreator(
LoadAllGroups(mod); LoadAllGroups(mod);
if (incorporateMetaChanges) if (incorporateMetaChanges)
IncorporateAllMetaChanges(mod, true, deleteDefaultMetaChanges); IncorporateAllMetaChanges(mod, true, deleteDefaultMetaChanges);
else if (deleteDefaultMetaChanges) else if (deleteDefaultMetaChanges)
ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, saveService, false); ModMetaEditor.DeleteDefaultValues(mod, metaFileManager, saveService, false);
return true; return true;
} }

View file

@ -1,7 +1,6 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Penumbra.GameData.Structs; using Penumbra.GameData.Structs;
using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager; using Penumbra.Mods.Manager;
using Penumbra.Services; using Penumbra.Services;
@ -28,6 +27,19 @@ public readonly struct ModMeta(Mod mod) : ISavable
{ nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) }, { nameof(Mod.ModTags), JToken.FromObject(mod.ModTags) },
{ nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) }, { nameof(Mod.DefaultPreferredItems), JToken.FromObject(mod.DefaultPreferredItems) },
}; };
if (mod.RequiredFeatures is not FeatureFlags.None)
{
var features = mod.RequiredFeatures;
var array = new JArray();
foreach (var flag in Enum.GetValues<FeatureFlags>())
{
if ((features & flag) is not FeatureFlags.None)
array.Add(flag.ToString());
}
jObject[nameof(Mod.RequiredFeatures)] = array;
}
using var jWriter = new JsonTextWriter(writer); using var jWriter = new JsonTextWriter(writer);
jWriter.Formatting = Formatting.Indented; jWriter.Formatting = Formatting.Indented;
jObject.WriteTo(jWriter); jObject.WriteTo(jWriter);
@ -60,6 +72,8 @@ public readonly struct ModMeta(Mod mod) : ISavable
var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values<string>().OfType<string>(); var modTags = (json[nameof(Mod.ModTags)] as JArray)?.Values<string>().OfType<string>();
var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values<ulong>().Select(i => (CustomItemId)i).ToHashSet() var defaultItems = (json[nameof(Mod.DefaultPreferredItems)] as JArray)?.Values<ulong>().Select(i => (CustomItemId)i).ToHashSet()
?? []; ?? [];
var requiredFeatureArray = (json[nameof(Mod.RequiredFeatures)] as JArray)?.Values<string>() ?? [];
var requiredFeatures = FeatureChecker.ParseFlags(mod.ModPath.Name, newName.Length > 0 ? newName : mod.Name.Length > 0 ? mod.Name : "Unknown", requiredFeatureArray!);
ModDataChangeType changes = 0; ModDataChangeType changes = 0;
if (mod.Name != newName) if (mod.Name != newName)
@ -111,6 +125,13 @@ public readonly struct ModMeta(Mod mod) : ISavable
editor.SaveService.ImmediateSave(new ModMeta(mod)); editor.SaveService.ImmediateSave(new ModMeta(mod));
} }
// Required features get checked during parsing, in which case the new required features signal invalid.
if (requiredFeatures != mod.RequiredFeatures)
{
changes |= ModDataChangeType.RequiredFeatures;
mod.RequiredFeatures = requiredFeatures;
}
changes |= ModLocalData.UpdateTags(mod, modTags, null); changes |= ModLocalData.UpdateTags(mod, modTags, null);
return changes; return changes;

View file

@ -48,6 +48,25 @@ public class CombinedDataContainer(IModGroup group) : IModDataContainer
return sb.ToString(0, sb.Length - 3); return sb.ToString(0, sb.Length - 3);
} }
public unsafe string GetDirectoryName()
{
if (Name.Length > 0)
return Name;
var index = GetDataIndex();
if (index == 0)
return "None";
var text = stackalloc char[IModGroup.MaxCombiningOptions].Slice(0, Group.Options.Count);
for (var i = 0; i < Group.Options.Count; ++i)
{
text[Group.Options.Count - 1 - i] = (index & 1) is 0 ? '0' : '1';
index >>= 1;
}
return new string(text);
}
public string GetFullName() public string GetFullName()
=> $"{Group.Name}: {GetName()}"; => $"{Group.Name}: {GetName()}";

View file

@ -0,0 +1,46 @@
using Newtonsoft.Json.Linq;
using OtterGui.Extensions;
using Penumbra.Meta.Manipulations;
using Penumbra.Mods.Editor;
using Penumbra.Mods.Groups;
using Penumbra.String.Classes;
namespace Penumbra.Mods.SubMods;
public sealed class ComplexDataContainer(ComplexModGroup group) : IModDataContainer
{
public IMod Mod
=> Group.Mod;
public IModGroup Group { get; } = group;
public Dictionary<Utf8GamePath, FullPath> Files { get; set; } = [];
public Dictionary<Utf8GamePath, FullPath> FileSwaps { get; set; } = [];
public MetaDictionary Manipulations { get; set; } = new();
public MaskedSetting Association = MaskedSetting.Zero;
public string Name { get; set; } = string.Empty;
public string GetName()
=> Name.Length > 0 ? Name : $"Container {Group.DataContainers.IndexOf(this)}";
public string GetDirectoryName()
=> Name.Length > 0 ? Name : $"{Group.DataContainers.IndexOf(this)}";
public string GetFullName()
=> $"{Group.Name}: {GetName()}";
public (int GroupIndex, int DataIndex) GetDataIndices()
=> (Group.GetIndex(), Group.DataContainers.IndexOf(this));
public ComplexDataContainer(ComplexModGroup group, JToken json)
: this(group)
{
SubMod.LoadDataContainer(json, this, group.Mod.ModPath);
var mask = json["AssociationMask"]?.ToObject<ulong>() ?? 0;
var value = json["AssociationMask"]?.ToObject<ulong>() ?? 0;
Association = new MaskedSetting(mask, value);
Name = json["Name"]?.ToObject<string>() ?? string.Empty;
}
}

View file

@ -0,0 +1,37 @@
using Newtonsoft.Json.Linq;
using OtterGui.Extensions;
using Penumbra.Mods.Groups;
namespace Penumbra.Mods.SubMods;
public sealed class ComplexSubMod(ComplexModGroup group) : IModOption
{
public Mod Mod
=> group.Mod;
public IModGroup Group { get; } = group;
public string Name { get; set; } = "Option";
public string FullName
=> $"{Group.Name}: {Name}";
public MaskedSetting Conditions = MaskedSetting.Zero;
public int Indentation = 0;
public string SubGroupLabel = string.Empty;
public string Description { get; set; } = string.Empty;
public int GetIndex()
=> Group.Options.IndexOf(this);
public ComplexSubMod(ComplexModGroup group, JToken json)
: this(group)
{
SubMod.LoadOptionData(json, this);
var mask = json["ConditionMask"]?.ToObject<ulong>() ?? 0;
var value = json["ConditionMask"]?.ToObject<ulong>() ?? 0;
Conditions = new MaskedSetting(mask, value);
Indentation = json["Indentation"]?.ToObject<int>() ?? 0;
SubGroupLabel = json["SubGroup"]?.ToObject<string>() ?? string.Empty;
}
}

View file

@ -27,6 +27,9 @@ public class DefaultSubMod(IMod mod) : IModDataContainer
public string GetName() public string GetName()
=> FullName; => FullName;
public string GetDirectoryName()
=> GetName();
public string GetFullName() public string GetFullName()
=> FullName; => FullName;

View file

@ -15,6 +15,7 @@ public interface IModDataContainer
public MetaDictionary Manipulations { get; set; } public MetaDictionary Manipulations { get; set; }
public string GetName(); public string GetName();
public string GetDirectoryName();
public string GetFullName(); public string GetFullName();
public (int GroupIndex, int DataIndex) GetDataIndices(); public (int GroupIndex, int DataIndex) GetDataIndices();
} }

View file

@ -0,0 +1,27 @@
using Penumbra.Mods.Groups;
using Penumbra.Mods.Settings;
namespace Penumbra.Mods.SubMods;
public readonly struct MaskedSetting(Setting mask, Setting value)
{
public const int MaxSettings = IModGroup.MaxMultiOptions;
public static readonly MaskedSetting Zero = new(Setting.Zero, Setting.Zero);
public static readonly MaskedSetting FullMask = new(Setting.AllBits(IModGroup.MaxComplexOptions), Setting.Zero);
public readonly Setting Mask = mask;
public readonly Setting Value = new(value.Value & mask.Value);
public MaskedSetting(ulong mask, ulong value)
: this(new Setting(mask), new Setting(value))
{ }
public MaskedSetting Limit(int numOptions)
=> new(Mask.Value & Setting.AllBits(numOptions).Value, Value.Value);
public bool IsZero
=> Mask.Value is 0;
public bool IsEnabled(Setting input)
=> (input.Value & Mask.Value) == Value.Value;
}

View file

@ -41,6 +41,9 @@ public abstract class OptionSubMod(IModGroup group) : IModOption, IModDataContai
public string GetName() public string GetName()
=> Name; => Name;
public string GetDirectoryName()
=> GetName();
public string GetFullName() public string GetFullName()
=> FullName; => FullName;

View file

@ -1,5 +1,6 @@
using Dalamud.Plugin; using Dalamud.Plugin;
using ImGuiNET; using Dalamud.Bindings.ImGui;
using Dalamud.Game;
using OtterGui; using OtterGui;
using OtterGui.Log; using OtterGui.Log;
using OtterGui.Services; using OtterGui.Services;
@ -21,6 +22,7 @@ using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManage
using Dalamud.Plugin.Services; using Dalamud.Plugin.Services;
using Lumina.Excel.Sheets; using Lumina.Excel.Sheets;
using Penumbra.GameData.Data; using Penumbra.GameData.Data;
using Penumbra.Interop;
using Penumbra.Interop.Hooks; using Penumbra.Interop.Hooks;
using Penumbra.Interop.Hooks.PostProcessing; using Penumbra.Interop.Hooks.PostProcessing;
using Penumbra.Interop.Hooks.ResourceLoading; using Penumbra.Interop.Hooks.ResourceLoading;
@ -209,10 +211,11 @@ public class Penumbra : IDalamudPlugin
public string GatherSupportInformation() public string GatherSupportInformation()
{ {
var sb = new StringBuilder(10240); var sb = new StringBuilder(10240);
var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory); var exists = _config.ModDirectory.Length > 0 && Directory.Exists(_config.ModDirectory);
var hdrEnabler = _services.GetService<RenderTargetHdrEnabler>(); var cloudSynced = exists && CloudApi.IsCloudSynced(_config.ModDirectory);
var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null; var hdrEnabler = _services.GetService<RenderTargetHdrEnabler>();
var drive = exists ? new DriveInfo(new DirectoryInfo(_config.ModDirectory).Root.FullName) : null;
sb.AppendLine("**Settings**"); sb.AppendLine("**Settings**");
sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n"); sb.Append($"> **`Plugin Version: `** {_validityChecker.Version}\n");
sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n"); sb.Append($"> **`Commit Hash: `** {_validityChecker.CommitHash}\n");
@ -221,7 +224,8 @@ public class Penumbra : IDalamudPlugin
sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n"); sb.Append($"> **`Operating System: `** {(Dalamud.Utility.Util.IsWine() ? "Mac/Linux (Wine)" : "Windows")}\n");
if (Dalamud.Utility.Util.IsWine()) if (Dalamud.Utility.Util.IsWine())
sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n"); sb.Append($"> **`Locale Environment Variables:`** {CollectLocaleEnvironmentVariables()}\n");
sb.Append($"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}\n"); sb.Append(
$"> **`Root Directory: `** `{_config.ModDirectory}`, {(exists ? "Exists" : "Not Existing")}{(cloudSynced ? ", Cloud-Synced" : "")}\n");
sb.Append( sb.Append(
$"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n"); $"> **`Free Drive Space: `** {(drive != null ? Functions.HumanReadableSize(drive.AvailableFreeSpace) : "Unknown")}\n");
sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n"); sb.Append($"> **`Game Data Files: `** {(_gameData.HasModifiedGameDataFiles ? "Modified" : "Pristine")}\n");
@ -229,10 +233,12 @@ public class Penumbra : IDalamudPlugin
sb.Append($"> **`Auto-UI-Reduplication: `** {_config.AutoReduplicateUiOnImport}\n"); sb.Append($"> **`Auto-UI-Reduplication: `** {_config.AutoReduplicateUiOnImport}\n");
sb.Append($"> **`Debug Mode: `** {_config.DebugMode}\n"); sb.Append($"> **`Debug Mode: `** {_config.DebugMode}\n");
sb.Append($"> **`Penumbra Reloads: `** {hdrEnabler.PenumbraReloadCount}\n"); sb.Append($"> **`Penumbra Reloads: `** {hdrEnabler.PenumbraReloadCount}\n");
sb.Append($"> **`HDR Enabled (from Start): `** {_config.HdrRenderTargets} ({hdrEnabler is { FirstLaunchHdrState: true, FirstLaunchHdrHookOverrideState: true }}){(hdrEnabler.HdrEnabledSuccess ? ", Detour Called" : ", **NEVER CALLED**")}\n"); sb.Append(
$"> **`HDR Enabled (from Start): `** {_config.HdrRenderTargets} ({hdrEnabler is { FirstLaunchHdrState: true, FirstLaunchHdrHookOverrideState: true }}){(hdrEnabler.HdrEnabledSuccess ? ", Detour Called" : ", **NEVER CALLED**")}\n");
sb.Append($"> **`Custom Shapes Enabled: `** {_config.EnableCustomShapes}\n"); sb.Append($"> **`Custom Shapes Enabled: `** {_config.EnableCustomShapes}\n");
sb.Append($"> **`Hook Overrides: `** {HookOverrides.Instance.IsCustomLoaded}\n"); sb.Append($"> **`Hook Overrides: `** {HookOverrides.Instance.IsCustomLoaded}\n");
sb.Append($"> **`Synchronous Load (Dalamud): `** {(_services.GetService<DalamudConfigService>().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")} (first Start: {hdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"})\n"); sb.Append(
$"> **`Synchronous Load (Dalamud): `** {(_services.GetService<DalamudConfigService>().GetDalamudConfig(DalamudConfigService.WaitingForPluginsOption, out bool v) ? v.ToString() : "Unknown")} (first Start: {hdrEnabler.FirstLaunchWaitForPluginsState?.ToString() ?? "Unknown"})\n");
sb.Append( sb.Append(
$"> **`Logging: `** Log: {_config.Ephemeral.EnableResourceLogging}, Watcher: {_config.Ephemeral.EnableResourceWatcher} ({_config.MaxResourceWatcherRecords})\n"); $"> **`Logging: `** Log: {_config.Ephemeral.EnableResourceLogging}, Watcher: {_config.Ephemeral.EnableResourceWatcher} ({_config.MaxResourceWatcherRecords})\n");
sb.Append($"> **`Use Ownership: `** {_config.UseOwnerNameForCharacterCollection}\n"); sb.Append($"> **`Use Ownership: `** {_config.UseOwnerNameForCharacterCollection}\n");

View file

@ -1,4 +1,4 @@
<Project Sdk="Dalamud.NET.Sdk/12.0.2"> <Project Sdk="Dalamud.NET.Sdk/13.1.0">
<PropertyGroup> <PropertyGroup>
<AssemblyTitle>Penumbra</AssemblyTitle> <AssemblyTitle>Penumbra</AssemblyTitle>
<Company>absolute gangstas</Company> <Company>absolute gangstas</Company>
@ -57,11 +57,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="EmbedIO" Version="3.5.2" /> <PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SharpCompress" Version="0.39.0" /> <PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.3" /> <PackageReference Include="SharpGLTF.Core" Version="1.0.5" />
<PackageReference Include="SharpGLTF.Toolkit" Version="1.0.3" /> <PackageReference Include="SharpGLTF.Toolkit" Version="1.0.5" />
<PackageReference Include="PeNet" Version="4.1.1" /> <PackageReference Include="PeNet" Version="5.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,5 +1,5 @@
{ {
"Author": "Ottermandias, Adam, Wintermute", "Author": "Ottermandias, Nylfae, Adam, Wintermute",
"Name": "Penumbra", "Name": "Penumbra",
"Punchline": "Runtime mod loader and manager.", "Punchline": "Runtime mod loader and manager.",
"Description": "Runtime mod loader and manager.", "Description": "Runtime mod loader and manager.",
@ -8,7 +8,7 @@
"RepoUrl": "https://github.com/xivdev/Penumbra", "RepoUrl": "https://github.com/xivdev/Penumbra",
"ApplicableVersion": "any", "ApplicableVersion": "any",
"Tags": [ "modding" ], "Tags": [ "modding" ],
"DalamudApiLevel": 12, "DalamudApiLevel": 13,
"LoadPriority": 69420, "LoadPriority": 69420,
"LoadRequiredState": 2, "LoadRequiredState": 2,
"LoadSync": true, "LoadSync": true,

View file

@ -81,6 +81,12 @@ public class CommunicatorService : IDisposable, IService
/// <inheritdoc cref="Communication.ResolvedFileChanged"/> /// <inheritdoc cref="Communication.ResolvedFileChanged"/>
public readonly ResolvedFileChanged ResolvedFileChanged = new(); public readonly ResolvedFileChanged ResolvedFileChanged = new();
/// <inheritdoc cref="Communication.PcpCreation"/>
public readonly PcpCreation PcpCreation = new();
/// <inheritdoc cref="Communication.PcpParsing"/>
public readonly PcpParsing PcpParsing = new();
public void Dispose() public void Dispose()
{ {
CollectionChange.Dispose(); CollectionChange.Dispose();
@ -105,5 +111,7 @@ public class CommunicatorService : IDisposable, IService
ChangedItemClick.Dispose(); ChangedItemClick.Dispose();
SelectTab.Dispose(); SelectTab.Dispose();
ResolvedFileChanged.Dispose(); ResolvedFileChanged.Dispose();
PcpCreation.Dispose();
PcpParsing.Dispose();
} }
} }

View file

@ -0,0 +1,209 @@
using OtterGui.Services;
using Penumbra.Mods.Manager;
namespace Penumbra.Services;
public class FileWatcher : IDisposable, IService
{
// TODO: use ConcurrentSet when it supports comparers in Luna.
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private readonly ModImportManager _modImportManager;
private readonly MessageService _messageService;
private readonly Configuration _config;
private bool _pausedConsumer;
private FileSystemWatcher? _fsw;
private CancellationTokenSource? _cts = new();
private Task? _consumer;
public FileWatcher(ModImportManager modImportManager, MessageService messageService, Configuration config)
{
_modImportManager = modImportManager;
_messageService = messageService;
_config = config;
if (_config.EnableDirectoryWatch)
{
SetupFileWatcher(_config.WatchDirectory);
SetupConsumerTask();
}
}
public void Toggle(bool value)
{
if (_config.EnableDirectoryWatch == value)
return;
_config.EnableDirectoryWatch = value;
_config.Save();
if (value)
{
SetupFileWatcher(_config.WatchDirectory);
SetupConsumerTask();
}
else
{
EndFileWatcher();
EndConsumerTask();
}
}
internal void PauseConsumer(bool pause)
=> _pausedConsumer = pause;
private void EndFileWatcher()
{
if (_fsw is null)
return;
_fsw.Dispose();
_fsw = null;
}
private void SetupFileWatcher(string directory)
{
EndFileWatcher();
_fsw = new FileSystemWatcher
{
IncludeSubdirectories = false,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
InternalBufferSize = 32 * 1024,
};
// Only wake us for the exact patterns we care about
_fsw.Filters.Add("*.pmp");
_fsw.Filters.Add("*.pcp");
_fsw.Filters.Add("*.ttmp");
_fsw.Filters.Add("*.ttmp2");
_fsw.Created += OnPath;
_fsw.Renamed += OnPath;
UpdateDirectory(directory);
}
private void EndConsumerTask()
{
if (_cts is not null)
{
_cts.Cancel();
_cts = null;
}
_consumer = null;
}
private void SetupConsumerTask()
{
EndConsumerTask();
_cts = new CancellationTokenSource();
_consumer = Task.Factory.StartNew(
() => ConsumerLoopAsync(_cts.Token),
_cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap();
}
public void UpdateDirectory(string newPath)
{
if (_config.WatchDirectory != newPath)
{
_config.WatchDirectory = newPath;
_config.Save();
}
if (_fsw is null)
return;
_fsw.EnableRaisingEvents = false;
if (!Directory.Exists(newPath) || newPath.Length is 0)
{
_fsw.Path = string.Empty;
}
else
{
_fsw.Path = newPath;
_fsw.EnableRaisingEvents = true;
}
}
private void OnPath(object? sender, FileSystemEventArgs e)
=> _pending.TryAdd(e.FullPath, 0);
private async Task ConsumerLoopAsync(CancellationToken token)
{
while (true)
{
var (path, _) = _pending.FirstOrDefault();
if (path is null || _pausedConsumer)
{
await Task.Delay(500, token).ConfigureAwait(false);
continue;
}
try
{
await ProcessOneAsync(path, token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Penumbra.Log.Debug("[FileWatcher] Canceled via Token.");
}
catch (Exception ex)
{
Penumbra.Log.Warning($"[FileWatcher] Error during Processing: {ex}");
}
finally
{
_pending.TryRemove(path, out _);
}
}
}
private async Task ProcessOneAsync(string path, CancellationToken token)
{
// Downloads often finish via rename; file may be locked briefly.
// Wait until it exists and is readable; also require two stable size checks.
const int maxTries = 40;
long lastLen = -1;
for (var i = 0; i < maxTries && !token.IsCancellationRequested; i++)
{
if (!File.Exists(path))
{
await Task.Delay(100, token);
continue;
}
try
{
var fi = new FileInfo(path);
var len = fi.Length;
if (len > 0 && len == lastLen)
{
if (_config.EnableAutomaticModImport)
_modImportManager.AddUnpack(path);
else
_messageService.AddMessage(new InstallNotification(_modImportManager, path), false);
return;
}
lastLen = len;
}
catch (IOException)
{
Penumbra.Log.Debug($"[FileWatcher] File is still being written to.");
}
catch (UnauthorizedAccessException)
{
Penumbra.Log.Debug($"[FileWatcher] File is locked.");
}
await Task.Delay(150, token);
}
}
public void Dispose()
{
EndConsumerTask();
EndFileWatcher();
}
}

View file

@ -0,0 +1,39 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.EventArgs;
using OtterGui.Text;
using Penumbra.Mods.Manager;
namespace Penumbra.Services;
public class InstallNotification(ModImportManager modImportManager, string filePath) : OtterGui.Classes.MessageService.IMessage
{
public string Message
=> "A new mod has been found!";
public NotificationType NotificationType
=> NotificationType.Info;
public uint NotificationDuration
=> uint.MaxValue;
public string NotificationTitle { get; } = Path.GetFileNameWithoutExtension(filePath);
public string LogMessage
=> $"A new mod has been found: {Path.GetFileName(filePath)}";
public void OnNotificationActions(INotificationDrawArgs args)
{
var region = ImGui.GetContentRegionAvail();
var buttonSize = new Vector2((region.X - ImGui.GetStyle().ItemSpacing.X) / 2, 0);
if (ImUtf8.ButtonEx("Install"u8, ""u8, buttonSize))
{
modImportManager.AddUnpack(filePath);
args.Notification.DismissNow();
}
ImGui.SameLine();
if (ImUtf8.ButtonEx("Ignore"u8, ""u8, buttonSize))
args.Notification.DismissNow();
}
}

View file

@ -1,6 +1,10 @@
using Dalamud.Interface.ImGuiNotification; using Dalamud.Interface.ImGuiNotification;
using Lumina.Data.Files;
using OtterGui.Classes; using OtterGui.Classes;
using OtterGui.Services; using OtterGui.Services;
using Lumina.Extensions;
using Penumbra.GameData.Files.Utility;
using Penumbra.Import.Textures;
using SharpCompress.Common; using SharpCompress.Common;
using SharpCompress.Readers; using SharpCompress.Readers;
using MdlFile = Penumbra.GameData.Files.MdlFile; using MdlFile = Penumbra.GameData.Files.MdlFile;
@ -296,6 +300,26 @@ public class MigrationManager(Configuration config) : IService
} }
} }
public void FixMipMaps(IReader reader, string directory, ExtractionOptions options)
{
var path = Path.Combine(directory, reader.Entry.Key!);
using var s = new MemoryStream();
using var e = reader.OpenEntryStream();
e.CopyTo(s);
var length = s.Position;
s.Seek(0, SeekOrigin.Begin);
var br = new BinaryReader(s, Encoding.UTF8, true);
var header = br.ReadStructure<TexFile.TexHeader>();
br.Dispose();
TexFileParser.FixMipOffsets(length, ref header, out var actualSize);
s.Seek(0, SeekOrigin.Begin);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var f = File.Open(path, FileMode.Create, FileAccess.Write);
f.Write(header);
f.Write(s.GetBuffer().AsSpan(80, (int)actualSize - 80));
}
/// <summary> Update the data of a .mdl file during TTMP extraction. Returns either the existing array or a new one. </summary> /// <summary> Update the data of a .mdl file during TTMP extraction. Returns either the existing array or a new one. </summary>
public byte[] MigrateTtmpModel(string path, byte[] data) public byte[] MigrateTtmpModel(string path, byte[] data)
{ {
@ -348,6 +372,25 @@ public class MigrationManager(Configuration config) : IService
} }
} }
public byte[] FixTtmpMipMaps(string path, byte[] data)
{
using var m = new MemoryStream(data);
var br = new BinaryReader(m, Encoding.UTF8, true);
var header = br.ReadStructure<TexFile.TexHeader>();
br.Dispose();
TexFileParser.FixMipOffsets(data.Length, ref header, out var actualSize);
if (actualSize == data.Length)
return data;
var ret = new byte[actualSize];
using var m2 = new MemoryStream(ret);
using var bw = new BinaryWriter(m2);
bw.Write(header);
bw.Write(data.AsSpan(80, (int)actualSize - 80));
return ret;
}
private static bool MigrateModel(string path, MdlFile mdl, bool createBackup) private static bool MigrateModel(string path, MdlFile mdl, bool createBackup)
{ {

View file

@ -0,0 +1,308 @@
using Dalamud.Game.ClientState.Objects.Types;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
using OtterGui.Services;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.Communication;
using Penumbra.GameData.Actors;
using Penumbra.GameData.Interop;
using Penumbra.GameData.Structs;
using Penumbra.Interop.PathResolving;
using Penumbra.Interop.ResourceTree;
using Penumbra.Meta.Manipulations;
using Penumbra.Mods;
using Penumbra.Mods.Groups;
using Penumbra.Mods.Manager;
using Penumbra.Mods.SubMods;
using Penumbra.String.Classes;
namespace Penumbra.Services;
public class PcpService : IApiService, IDisposable
{
public const string Extension = ".pcp";
private readonly Configuration _config;
private readonly SaveService _files;
private readonly ResourceTreeFactory _treeFactory;
private readonly ObjectManager _objectManager;
private readonly ActorManager _actors;
private readonly FrameworkManager _framework;
private readonly CollectionResolver _collectionResolver;
private readonly CollectionManager _collections;
private readonly ModCreator _modCreator;
private readonly ModExportManager _modExport;
private readonly CommunicatorService _communicator;
private readonly SHA1 _sha1 = SHA1.Create();
private readonly ModFileSystem _fileSystem;
private readonly ModManager _mods;
public PcpService(Configuration config,
SaveService files,
ResourceTreeFactory treeFactory,
ObjectManager objectManager,
ActorManager actors,
FrameworkManager framework,
CollectionManager collections,
CollectionResolver collectionResolver,
ModCreator modCreator,
ModExportManager modExport,
CommunicatorService communicator,
ModFileSystem fileSystem,
ModManager mods)
{
_config = config;
_files = files;
_treeFactory = treeFactory;
_objectManager = objectManager;
_actors = actors;
_framework = framework;
_collectionResolver = collectionResolver;
_collections = collections;
_modCreator = modCreator;
_modExport = modExport;
_communicator = communicator;
_fileSystem = fileSystem;
_mods = mods;
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.PcpService);
}
public void CleanPcpMods()
{
var mods = _mods.Where(m => m.ModTags.Contains("PCP")).ToList();
Penumbra.Log.Information($"[PCPService] Deleting {mods.Count} mods containing the tag PCP.");
foreach (var mod in mods)
_mods.DeleteMod(mod);
}
public void CleanPcpCollections()
{
var collections = _collections.Storage.Where(c => c.Identity.Name.StartsWith("PCP/")).ToList();
Penumbra.Log.Information($"[PCPService] Deleting {collections.Count} collections starting with PCP/.");
foreach (var collection in collections)
_collections.Storage.RemoveCollection(collection);
}
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory)
{
if (type is not ModPathChangeType.Added || _config.PcpSettings.DisableHandling || newDirectory is null)
return;
try
{
var file = Path.Combine(newDirectory.FullName, "character.json");
if (!File.Exists(file))
{
// First version had collection.json, changed.
var oldFile = Path.Combine(newDirectory.FullName, "collection.json");
if (File.Exists(oldFile))
{
Penumbra.Log.Information("[PCPService] Renaming old PCP file from collection.json to character.json.");
File.Move(oldFile, file, true);
}
else
return;
}
Penumbra.Log.Information($"[PCPService] Found a PCP file for {mod.Name}, applying.");
var text = File.ReadAllText(file);
var jObj = JObject.Parse(text);
var collection = ModCollection.Empty;
// Create collection.
if (_config.PcpSettings.CreateCollection)
{
var identifier = _actors.FromJson(jObj["Actor"] as JObject);
if (identifier.IsValid && jObj["Collection"]?.ToObject<string>() is { } collectionName)
{
var name = $"PCP/{collectionName}";
if (_collections.Storage.AddCollection(name, null))
{
collection = _collections.Storage[^1];
_collections.Editor.SetModState(collection, mod, true);
// Assign collection.
if (_config.PcpSettings.AssignCollection)
{
var identifierGroup = _collections.Active.Individuals.GetGroup(identifier);
_collections.Active.SetCollection(collection, CollectionType.Individual, identifierGroup);
}
}
}
}
// Move to folder.
if (_fileSystem.TryGetValue(mod, out var leaf))
{
try
{
var folder = _fileSystem.FindOrCreateAllFolders(_config.PcpSettings.FolderName);
_fileSystem.Move(leaf, folder);
}
catch
{
// ignored.
}
}
// Invoke IPC.
if (_config.PcpSettings.AllowIpc)
_communicator.PcpParsing.Invoke(jObj, mod.Identifier, collection.Identity.Id);
}
catch (Exception ex)
{
Penumbra.Log.Error($"Error reading the character.json file from {mod.Identifier}:\n{ex}");
}
}
public void Dispose()
=> _communicator.ModPathChanged.Unsubscribe(OnModPathChange);
public async Task<(bool, string)> CreatePcp(ObjectIndex objectIndex, string note = "", CancellationToken cancel = default)
{
try
{
Penumbra.Log.Information($"[PCPService] Creating PCP file for game object {objectIndex.Index}.");
var (identifier, tree, meta) = await _framework.Framework.RunOnFrameworkThread(() =>
{
var (actor, identifier) = CheckActor(objectIndex);
cancel.ThrowIfCancellationRequested();
unsafe
{
var collection = _collectionResolver.IdentifyCollection((GameObject*)actor.Address, true);
if (!collection.Valid || !collection.ModCollection.HasCache)
throw new Exception($"Actor {identifier} has no mods applying, nothing to do.");
cancel.ThrowIfCancellationRequested();
if (_treeFactory.FromCharacter(actor, 0) is not { } tree)
throw new Exception($"Unable to fetch modded resources for {identifier}.");
var meta = new MetaDictionary(collection.ModCollection.MetaCache, actor.Address);
return (identifier.CreatePermanent(), tree, meta);
}
});
cancel.ThrowIfCancellationRequested();
var time = DateTime.Now;
var modDirectory = CreateMod(identifier, note, time);
await CreateDefaultMod(modDirectory, meta, tree, cancel);
await CreateCollectionInfo(modDirectory, objectIndex, identifier, note, time, cancel);
var file = ZipUp(modDirectory);
return (true, file);
}
catch (Exception ex)
{
return (false, ex.Message);
}
}
private static string ZipUp(DirectoryInfo directory)
{
var fileName = directory.FullName + Extension;
ZipFile.CreateFromDirectory(directory.FullName, fileName, CompressionLevel.Optimal, false);
directory.Delete(true);
return fileName;
}
private async Task CreateCollectionInfo(DirectoryInfo directory, ObjectIndex index, ActorIdentifier actor, string note, DateTime time,
CancellationToken cancel = default)
{
var jObj = new JObject
{
["Version"] = 1,
["Actor"] = actor.ToJson(),
["Mod"] = directory.Name,
["Collection"] = note.Length > 0 ? $"{actor.ToName()}: {note}" : actor.ToName(),
["Time"] = time,
["Note"] = note,
};
if (note.Length > 0)
cancel.ThrowIfCancellationRequested();
if (_config.PcpSettings.AllowIpc)
await _framework.Framework.RunOnFrameworkThread(() => _communicator.PcpCreation.Invoke(jObj, index.Index, directory.FullName));
var filePath = Path.Combine(directory.FullName, "character.json");
await using var file = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew);
await using var stream = new StreamWriter(file);
await using var json = new JsonTextWriter(stream);
json.Formatting = Formatting.Indented;
await jObj.WriteToAsync(json, cancel);
}
private DirectoryInfo CreateMod(ActorIdentifier actor, string note, DateTime time)
{
var directory = _modExport.ExportDirectory;
directory.Create();
var actorName = actor.ToName();
var authorName = _actors.GetCurrentPlayer().ToName();
var suffix = note.Length > 0
? note
: time.ToString("yyyy-MM-ddTHH\\:mm", CultureInfo.InvariantCulture);
var modName = $"{actorName} - {suffix}";
var description = $"On-Screen Data for {actorName} as snapshotted on {time}.";
return _modCreator.CreateEmptyMod(directory, modName, description, authorName, "PCP")
?? throw new Exception($"Unable to create mod {modName} in {directory.FullName}.");
}
private async Task CreateDefaultMod(DirectoryInfo modDirectory, MetaDictionary meta, ResourceTree tree,
CancellationToken cancel = default)
{
var subDirectory = modDirectory.CreateSubdirectory("files");
var subMod = new DefaultSubMod(null!)
{
Manipulations = meta,
};
foreach (var node in tree.FlatNodes)
{
cancel.ThrowIfCancellationRequested();
var gamePath = node.GamePath;
var fullPath = node.FullPath;
if (fullPath.IsRooted)
{
var hash = await _sha1.ComputeHashAsync(File.OpenRead(fullPath.FullName), cancel).ConfigureAwait(false);
cancel.ThrowIfCancellationRequested();
var name = Convert.ToHexString(hash) + fullPath.Extension;
var newFile = Path.Combine(subDirectory.FullName, name);
if (!File.Exists(newFile))
File.Copy(fullPath.FullName, newFile);
subMod.Files.TryAdd(gamePath, new FullPath(newFile));
}
else if (gamePath.Path != fullPath.InternalName)
{
subMod.FileSwaps.TryAdd(gamePath, fullPath);
}
}
cancel.ThrowIfCancellationRequested();
var saveGroup = new ModSaveGroup(modDirectory, subMod, _config.ReplaceNonAsciiOnImport);
var filePath = _files.FileNames.OptionGroupFile(modDirectory.FullName, -1, string.Empty, _config.ReplaceNonAsciiOnImport);
cancel.ThrowIfCancellationRequested();
await using var fileStream = File.Open(filePath, File.Exists(filePath) ? FileMode.Truncate : FileMode.CreateNew);
await using var writer = new StreamWriter(fileStream);
saveGroup.Save(writer);
}
private (ICharacter Actor, ActorIdentifier Identifier) CheckActor(ObjectIndex objectIndex)
{
var actor = _objectManager[objectIndex];
if (!actor.Valid)
throw new Exception($"No Actor at index {objectIndex} found.");
if (!actor.Identifier(_actors, out var identifier))
throw new Exception($"Could not create valid identifier for actor at index {objectIndex}.");
if (!actor.IsCharacter)
throw new Exception($"Actor {identifier} at index {objectIndex} is not a valid character.");
if (!actor.Model.Valid)
throw new Exception($"Actor {identifier} at index {objectIndex} has no model.");
if (_objectManager.Objects.CreateObjectReference(actor.Address) is not ICharacter character)
throw new Exception($"Actor {identifier} at index {objectIndex} could not be converted to ICharacter");
return (character, identifier);
}
}

Some files were not shown because too many files have changed in this diff Show more