sigma update

This commit is contained in:
2026-01-16 11:00:58 +09:00
parent 59ed03a825
commit 96123d00a2
51 changed files with 6640 additions and 1382 deletions

View File

@@ -571,36 +571,19 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
if (localPlayerAddress == nint.Zero)
return nint.Zero;
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (candidateAddress == nint.Zero)
return nint.Zero;
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind is not (DalamudObjectKind.MountType or DalamudObjectKind.Companion))
continue;
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
return candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion
? candidateAddress
: nint.Zero;
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
@@ -620,22 +603,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}
@@ -655,23 +622,6 @@ public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorS
return candidate;
}
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
continue;
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
continue;
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Buddy)
continue;
if (ResolveOwnerId(candidate) == ownerEntityId)
return obj.Address;
}
return nint.Zero;
}

View File

@@ -8,18 +8,26 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LightlessSync.UI.Services;
using LightlessSync.LightlessConfiguration;
using LightlessSync.LightlessConfiguration.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace LightlessSync.Services.Chat;
public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedService
{
private const int MaxMessageHistory = 150;
private const int MaxMessageHistory = 200;
internal const int MaxOutgoingLength = 200;
private const int MaxUnreadCount = 999;
private const string ZoneUnavailableMessage = "Zone chat is only available in major cities.";
private const string ZoneChannelKey = "zone";
private const int MaxReportReasonLength = 100;
private const int MaxReportContextLength = 1000;
private static readonly JsonSerializerOptions PersistedHistorySerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly ApiController _apiController;
private readonly DalamudUtilService _dalamudUtilService;
@@ -376,6 +384,7 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
public Task StartAsync(CancellationToken cancellationToken)
{
LoadPersistedSyncshellHistory();
Mediator.Subscribe<DalamudLoginMessage>(this, _ => HandleLogin());
Mediator.Subscribe<DalamudLogoutMessage>(this, _ => HandleLogout());
Mediator.Subscribe<ZoneSwitchEndMessage>(this, _ => ScheduleZonePresenceUpdate());
@@ -1000,11 +1009,22 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
private void OnChatMessageReceived(ChatMessageDto dto)
{
var descriptor = dto.Channel.WithNormalizedCustomKey();
var key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
var fromSelf = IsMessageFromSelf(dto, key);
var message = BuildMessage(dto, fromSelf);
ChatChannelDescriptor descriptor = dto.Channel.WithNormalizedCustomKey();
string key = descriptor.Type == ChatChannelType.Zone ? ZoneChannelKey : BuildChannelKey(descriptor);
bool fromSelf = IsMessageFromSelf(dto, key);
ChatMessageEntry message = BuildMessage(dto, fromSelf);
bool mentionNotificationsEnabled = _chatConfigService.Current.EnableMentionNotifications;
bool notifyMention = mentionNotificationsEnabled
&& !fromSelf
&& descriptor.Type == ChatChannelType.Group
&& TryGetSelfMentionToken(dto.Message, out _);
string? mentionChannelName = null;
string? mentionSenderName = null;
bool publishChannelList = false;
bool shouldPersistHistory = _chatConfigService.Current.PersistSyncshellHistory;
List<PersistedChatMessage>? persistedMessages = null;
string? persistedChannelKey = null;
using (_sync.EnterScope())
{
@@ -1042,6 +1062,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
state.Messages.RemoveAt(0);
}
if (notifyMention)
{
mentionChannelName = state.DisplayName;
mentionSenderName = message.DisplayName;
}
if (string.Equals(_activeChannelKey, key, StringComparison.Ordinal))
{
state.HasUnread = false;
@@ -1058,10 +1084,29 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
}
MarkChannelsSnapshotDirtyLocked();
if (shouldPersistHistory && state.Type == ChatChannelType.Group)
{
persistedChannelKey = state.Key;
persistedMessages = BuildPersistedHistoryLocked(state);
}
}
Mediator.Publish(new ChatChannelMessageAdded(key, message));
if (persistedMessages is not null && persistedChannelKey is not null)
{
PersistSyncshellHistory(persistedChannelKey, persistedMessages);
}
if (notifyMention)
{
string channelName = mentionChannelName ?? "Syncshell";
string senderName = mentionSenderName ?? "Someone";
string notificationText = $"You were mentioned by {senderName} in {channelName}.";
Mediator.Publish(new NotificationMessage("Syncshell mention", notificationText, NotificationType.Info));
}
if (publishChannelList)
{
using (_sync.EnterScope())
@@ -1108,6 +1153,113 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return false;
}
private bool TryGetSelfMentionToken(string message, out string matchedToken)
{
matchedToken = string.Empty;
if (string.IsNullOrWhiteSpace(message))
{
return false;
}
HashSet<string> tokens = BuildSelfMentionTokens();
if (tokens.Count == 0)
{
return false;
}
return TryFindMentionToken(message, tokens, out matchedToken);
}
private HashSet<string> BuildSelfMentionTokens()
{
HashSet<string> tokens = new(StringComparer.OrdinalIgnoreCase);
string uid = _apiController.UID;
if (IsValidMentionToken(uid))
{
tokens.Add(uid);
}
string displayName = _apiController.DisplayName;
if (IsValidMentionToken(displayName))
{
tokens.Add(displayName);
}
return tokens;
}
private static bool IsValidMentionToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return false;
}
for (int i = 0; i < value.Length; i++)
{
if (!IsMentionChar(value[i]))
{
return false;
}
}
return true;
}
private static bool TryFindMentionToken(string message, IReadOnlyCollection<string> tokens, out string matchedToken)
{
matchedToken = string.Empty;
if (tokens.Count == 0 || string.IsNullOrEmpty(message))
{
return false;
}
int index = 0;
while (index < message.Length)
{
if (message[index] != '@')
{
index++;
continue;
}
if (index > 0 && IsMentionChar(message[index - 1]))
{
index++;
continue;
}
int start = index + 1;
int end = start;
while (end < message.Length && IsMentionChar(message[end]))
{
end++;
}
if (end == start)
{
index++;
continue;
}
string token = message.Substring(start, end - start);
if (tokens.Contains(token))
{
matchedToken = token;
return true;
}
index = end;
}
return false;
}
private static bool IsMentionChar(char value)
{
return char.IsLetterOrDigit(value) || value == '_' || value == '-' || value == '\'';
}
private ChatMessageEntry BuildMessage(ChatMessageDto dto, bool fromSelf)
{
var displayName = ResolveDisplayName(dto, fromSelf);
@@ -1364,6 +1516,313 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
return 0;
}
private void LoadPersistedSyncshellHistory()
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count == 0)
{
return;
}
List<string> invalidKeys = new();
foreach (KeyValuePair<string, string> entry in persisted)
{
if (string.IsNullOrWhiteSpace(entry.Key) || string.IsNullOrWhiteSpace(entry.Value))
{
invalidKeys.Add(entry.Key);
continue;
}
if (!TryDecodePersistedHistory(entry.Value, out List<PersistedChatMessage> persistedMessages))
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
if (persistedMessages.Count > MaxMessageHistory)
{
int startIndex = Math.Max(0, persistedMessages.Count - MaxMessageHistory);
persistedMessages = persistedMessages.GetRange(startIndex, persistedMessages.Count - startIndex);
}
List<ChatMessageEntry> restoredMessages = new(persistedMessages.Count);
foreach (PersistedChatMessage persistedMessage in persistedMessages)
{
if (!TryBuildRestoredMessage(entry.Key, persistedMessage, out ChatMessageEntry restoredMessage))
{
continue;
}
restoredMessages.Add(restoredMessage);
}
if (restoredMessages.Count == 0)
{
invalidKeys.Add(entry.Key);
continue;
}
using (_sync.EnterScope())
{
_messageHistoryCache[entry.Key] = restoredMessages;
}
}
if (invalidKeys.Count > 0)
{
foreach (string key in invalidKeys)
{
persisted.Remove(key);
}
_chatConfigService.Save();
}
}
private List<PersistedChatMessage> BuildPersistedHistoryLocked(ChatChannelState state)
{
int startIndex = Math.Max(0, state.Messages.Count - MaxMessageHistory);
List<PersistedChatMessage> persistedMessages = new(state.Messages.Count - startIndex);
for (int i = startIndex; i < state.Messages.Count; i++)
{
ChatMessageEntry entry = state.Messages[i];
if (entry.Payload is not { } payload)
{
continue;
}
persistedMessages.Add(new PersistedChatMessage(
payload.Message,
entry.DisplayName,
entry.FromSelf,
entry.ReceivedAtUtc,
payload.SentAtUtc));
}
return persistedMessages;
}
private void PersistSyncshellHistory(string channelKey, List<PersistedChatMessage> persistedMessages)
{
if (!_chatConfigService.Current.PersistSyncshellHistory)
{
return;
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persistedMessages.Count == 0)
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
string? base64 = EncodePersistedMessages(persistedMessages);
if (string.IsNullOrWhiteSpace(base64))
{
if (persisted.Remove(channelKey))
{
_chatConfigService.Save();
}
return;
}
persisted[channelKey] = base64;
_chatConfigService.Save();
}
private static string? EncodePersistedMessages(List<PersistedChatMessage> persistedMessages)
{
if (persistedMessages.Count == 0)
{
return null;
}
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(persistedMessages, PersistedHistorySerializerOptions);
return Convert.ToBase64String(jsonBytes);
}
private static bool TryDecodePersistedHistory(string base64, out List<PersistedChatMessage> persistedMessages)
{
persistedMessages = new List<PersistedChatMessage>();
if (string.IsNullOrWhiteSpace(base64))
{
return false;
}
try
{
byte[] jsonBytes = Convert.FromBase64String(base64);
List<PersistedChatMessage>? decoded = JsonSerializer.Deserialize<List<PersistedChatMessage>>(jsonBytes, PersistedHistorySerializerOptions);
if (decoded is null)
{
return false;
}
persistedMessages = decoded;
return true;
}
catch
{
return false;
}
}
private static bool TryBuildRestoredMessage(string channelKey, PersistedChatMessage persistedMessage, out ChatMessageEntry restoredMessage)
{
restoredMessage = default;
string messageText = persistedMessage.Message;
DateTime sentAtUtc = persistedMessage.SentAtUtc;
if (string.IsNullOrWhiteSpace(messageText) && persistedMessage.LegacyPayload is { } legacy)
{
messageText = legacy.Message;
sentAtUtc = legacy.SentAtUtc;
}
if (string.IsNullOrWhiteSpace(messageText))
{
return false;
}
ChatChannelDescriptor descriptor = BuildDescriptorFromChannelKey(channelKey);
ChatSenderDescriptor sender = new ChatSenderDescriptor(
ChatSenderKind.Anonymous,
string.Empty,
null,
null,
null,
false);
ChatMessageDto payload = new ChatMessageDto(descriptor, sender, messageText, sentAtUtc, string.Empty);
restoredMessage = new ChatMessageEntry(payload, persistedMessage.DisplayName, persistedMessage.FromSelf, persistedMessage.ReceivedAtUtc);
return true;
}
private static ChatChannelDescriptor BuildDescriptorFromChannelKey(string channelKey)
{
if (string.Equals(channelKey, ZoneChannelKey, StringComparison.Ordinal))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Zone };
}
int separatorIndex = channelKey.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= channelKey.Length - 1)
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string typeValue = channelKey[..separatorIndex];
if (!int.TryParse(typeValue, out int parsedType))
{
return new ChatChannelDescriptor { Type = ChatChannelType.Group };
}
string customKey = channelKey[(separatorIndex + 1)..];
ChatChannelType channelType = parsedType switch
{
(int)ChatChannelType.Zone => ChatChannelType.Zone,
(int)ChatChannelType.Group => ChatChannelType.Group,
_ => ChatChannelType.Group
};
return new ChatChannelDescriptor
{
Type = channelType,
CustomKey = customKey
};
}
public void ClearPersistedSyncshellHistory(bool clearLoadedMessages)
{
bool shouldPublish = false;
bool saveConfig = false;
using (_sync.EnterScope())
{
Dictionary<string, List<ChatMessageEntry>> cache = _messageHistoryCache;
if (cache.Count > 0)
{
List<string> keysToRemove = new();
foreach (string key in cache.Keys)
{
if (!string.Equals(key, ZoneChannelKey, StringComparison.Ordinal))
{
keysToRemove.Add(key);
}
}
foreach (string key in keysToRemove)
{
cache.Remove(key);
}
if (keysToRemove.Count > 0)
{
shouldPublish = true;
}
}
if (clearLoadedMessages)
{
foreach (ChatChannelState state in _channels.Values)
{
if (state.Type != ChatChannelType.Group)
{
continue;
}
if (state.Messages.Count == 0 && state.UnreadCount == 0 && !state.HasUnread)
{
continue;
}
state.Messages.Clear();
state.HasUnread = false;
state.UnreadCount = 0;
_lastReadCounts[state.Key] = 0;
shouldPublish = true;
}
}
Dictionary<string, string> persisted = _chatConfigService.Current.SyncshellChannelHistory;
if (persisted.Count > 0)
{
persisted.Clear();
saveConfig = true;
}
if (shouldPublish)
{
MarkChannelsSnapshotDirtyLocked();
}
}
if (saveConfig)
{
_chatConfigService.Save();
}
if (shouldPublish)
{
PublishChannelListChanged();
}
}
private sealed class ChatChannelState
{
public ChatChannelState(string key, ChatChannelType type, string displayName, ChatChannelDescriptor descriptor)
@@ -1400,4 +1859,12 @@ public sealed class ZoneChatService : DisposableMediatorSubscriberBase, IHostedS
bool IsOwner);
private readonly record struct PendingSelfMessage(string ChannelKey, string Message);
public sealed record PersistedChatMessage(
string Message = "",
string DisplayName = "",
bool FromSelf = false,
DateTime ReceivedAtUtc = default,
DateTime SentAtUtc = default,
[property: JsonPropertyName("Payload")] ChatMessageDto? LegacyPayload = null);
}

View File

@@ -1,224 +0,0 @@
using Microsoft.Extensions.Logging;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace LightlessSync.Services.Compactor
{
/// <summary>
/// This batch service is made for the File Frag command, because of each file needing to use this command.
/// It's better to combine into one big command in batches then doing each command on each compressed call.
/// </summary>
public sealed partial class BatchFilefragService : IDisposable
{
private readonly Channel<(string path, TaskCompletionSource<bool> tcs)> _ch;
private readonly Task _worker;
private readonly bool _useShell;
private readonly ILogger _log;
private readonly int _batchSize;
private readonly TimeSpan _flushDelay;
private readonly CancellationTokenSource _cts = new();
public delegate (bool ok, string stdout, string stderr, int exitCode) RunDirect(string fileName, IEnumerable<string> args, string? workingDir, int timeoutMs);
private readonly RunDirect _runDirect;
public delegate (bool ok, string stdout, string stderr, int exitCode) RunShell(string command, string? workingDir, int timeoutMs);
private readonly RunShell _runShell;
public BatchFilefragService(bool useShell, ILogger log, int batchSize = 128, int flushMs = 25, RunDirect? runDirect = null, RunShell? runShell = null)
{
_useShell = useShell;
_log = log;
_batchSize = Math.Max(8, batchSize);
_flushDelay = TimeSpan.FromMilliseconds(Math.Max(5, flushMs));
_ch = Channel.CreateUnbounded<(string, TaskCompletionSource<bool>)>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
// require runners to be setup, wouldnt start otherwise
if (runDirect is null || runShell is null)
throw new ArgumentNullException(nameof(runDirect), "Provide process runners from FileCompactor");
_runDirect = runDirect;
_runShell = runShell;
_worker = Task.Run(ProcessAsync, _cts.Token);
}
/// <summary>
/// Checks if the file is compressed using Btrfs using tasks
/// </summary>
/// <param name="linuxPath">Linux/Wine path given for the file.</param>
/// <param name="ct">Cancellation Token</param>
/// <returns>If it was compressed or not</returns>
public Task<bool> IsCompressedAsync(string linuxPath, CancellationToken ct = default)
{
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_ch.Writer.TryWrite((linuxPath, tcs)))
{
tcs.TrySetResult(false);
return tcs.Task;
}
if (ct.CanBeCanceled)
{
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
_ = tcs.Task.ContinueWith(_ => reg.Dispose(), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}
return tcs.Task;
}
/// <summary>
/// Process the pending compression tasks asynchronously
/// </summary>
/// <returns>Task</returns>
private async Task ProcessAsync()
{
var reader = _ch.Reader;
var pending = new List<(string path, TaskCompletionSource<bool> tcs)>(_batchSize);
try
{
while (await reader.WaitToReadAsync(_cts.Token).ConfigureAwait(false))
{
if (!reader.TryRead(out var first)) continue;
pending.Add(first);
var flushAt = DateTime.UtcNow + _flushDelay;
while (pending.Count < _batchSize && DateTime.UtcNow < flushAt)
{
if (reader.TryRead(out var item))
{
pending.Add(item);
continue;
}
if ((flushAt - DateTime.UtcNow) <= TimeSpan.Zero) break;
try
{
await Task.Delay(TimeSpan.FromMilliseconds(5), _cts.Token).ConfigureAwait(false);
}
catch
{
break;
}
}
try
{
var map = RunBatch(pending.Select(p => p.path));
foreach (var (path, tcs) in pending)
{
tcs.TrySetResult(map.TryGetValue(path, out var c) && c);
}
}
catch (Exception ex)
{
_log.LogDebug(ex, "filefrag batch failed. falling back to false");
foreach (var (_, tcs) in pending)
{
tcs.TrySetResult(false);
}
}
finally
{
pending.Clear();
}
}
}
catch (OperationCanceledException)
{
//Shutting down worker, exception called
}
}
/// <summary>
/// Running the batch of each file in the queue in one file frag command.
/// </summary>
/// <param name="paths">Paths that are needed for the command building for the batch return</param>
/// <returns>Path of the file and if it went correctly</returns>
/// <exception cref="InvalidOperationException">Failing to start filefrag on the system if this exception is found</exception>
private Dictionary<string, bool> RunBatch(IEnumerable<string> paths)
{
var list = paths.Distinct(StringComparer.Ordinal).ToList();
var result = list.ToDictionary(p => p, _ => false, StringComparer.Ordinal);
(bool ok, string stdout, string stderr, int code) res;
if (_useShell)
{
var inner = "filefrag -v -- " + string.Join(' ', list.Select(QuoteSingle));
res = _runShell(inner, timeoutMs: 15000, workingDir: "/");
}
else
{
var args = new List<string> { "-v", "--" };
args.AddRange(list);
res = _runDirect("filefrag", args, workingDir: "/", timeoutMs: 15000);
}
if (!string.IsNullOrWhiteSpace(res.stderr))
_log.LogTrace("filefrag stderr (batch): {err}", res.stderr.Trim());
ParseFilefrag(res.stdout, result);
return result;
}
/// <summary>
/// Parsing the string given from the File Frag command into mapping
/// </summary>
/// <param name="output">Output of the process from the File Frag</param>
/// <param name="map">Mapping of the processed files</param>
private static void ParseFilefrag(string output, Dictionary<string, bool> map)
{
var reHeaderColon = ColonRegex();
var reHeaderSize = SizeRegex();
string? current = null;
using var sr = new StringReader(output);
for (string? line = sr.ReadLine(); line != null; line = sr.ReadLine())
{
var m1 = reHeaderColon.Match(line);
if (m1.Success) { current = m1.Groups[1].Value; continue; }
var m2 = reHeaderSize.Match(line);
if (m2.Success) { current = m2.Groups[1].Value; continue; }
if (current is not null && line.Contains("flags:", StringComparison.OrdinalIgnoreCase) &&
line.Contains("compressed", StringComparison.OrdinalIgnoreCase) && map.ContainsKey(current))
{
map[current] = true;
}
}
}
private static string QuoteSingle(string s) => "'" + s.Replace("'", "'\\''", StringComparison.Ordinal) + "'";
/// <summary>
/// Regex of the File Size return on the Linux/Wine systems, giving back the amount
/// </summary>
/// <returns>Regex of the File Size</returns>
[GeneratedRegex(@"^File size of (/.+?) is ", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex SizeRegex();
/// <summary>
/// Regex on colons return on the Linux/Wine systems
/// </summary>
/// <returns>Regex of the colons in the given path</returns>
[GeneratedRegex(@"^(/.+?):\s", RegexOptions.ExplicitCapture | RegexOptions.CultureInvariant, matchTimeoutMilliseconds: 500)]
private static partial Regex ColonRegex();
public void Dispose()
{
_ch.Writer.TryComplete();
_cts.Cancel();
try
{
_worker.Wait(TimeSpan.FromSeconds(2), _cts.Token);
}
catch
{
// Ignore the catch in dispose
}
_cts.Dispose();
}
}
}

View File

@@ -424,38 +424,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (playerPointer == IntPtr.Zero) return IntPtr.Zero;
var playerAddress = playerPointer.Value;
var ownerEntityId = ((Character*)playerAddress)->EntityId;
var candidateAddress = _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
if (ownerEntityId == 0) return candidateAddress;
if (playerAddress == _actorObjectService.LocalPlayerAddress)
{
var localOwned = _actorObjectService.LocalMinionOrMountAddress;
if (localOwned != nint.Zero)
{
return localOwned;
}
}
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if ((candidateKind == DalamudObjectKind.MountType || candidateKind == DalamudObjectKind.Companion)
&& ResolveOwnerId(candidate) == ownerEntityId)
{
return candidateAddress;
}
}
var ownedObject = FindOwnedObject(ownerEntityId, playerAddress, static kind =>
kind == DalamudObjectKind.MountType || kind == DalamudObjectKind.Companion);
if (ownedObject != nint.Zero)
{
return ownedObject;
}
return candidateAddress;
return _objectTable.GetObjectAddress(((GameObject*)playerAddress)->ObjectIndex + 1);
}
public async Task<IntPtr> GetMinionOrMountAsync(IntPtr? playerPointer = null)
@@ -485,7 +454,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
}
}
return FindOwnedPet(ownerEntityId, ownerAddress);
return IntPtr.Zero;
}
public async Task<IntPtr> GetPetAsync(IntPtr? playerPointer = null)
@@ -493,69 +462,6 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return await RunOnFrameworkThread(() => GetPetPtr(playerPointer)).ConfigureAwait(false);
}
private unsafe nint FindOwnedObject(uint ownerEntityId, nint ownerAddress, Func<DalamudObjectKind, bool> matchesKind)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (!matchesKind(obj.ObjectKind))
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private unsafe nint FindOwnedPet(uint ownerEntityId, nint ownerAddress)
{
if (ownerEntityId == 0)
{
return nint.Zero;
}
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == ownerAddress)
{
continue;
}
if (obj.ObjectKind != DalamudObjectKind.BattleNpc)
{
continue;
}
var candidate = (GameObject*)obj.Address;
if (candidate->BattleNpcSubKind != BattleNpcSubKind.Pet)
{
continue;
}
if (ResolveOwnerId(candidate) == ownerEntityId)
{
return obj.Address;
}
}
return nint.Zero;
}
private static unsafe bool IsPetMatch(GameObject* candidate, uint ownerEntityId)
{
if (candidate == null)

View File

@@ -21,6 +21,12 @@ public record SwitchToIntroUiMessage : MessageBase;
public record SwitchToMainUiMessage : MessageBase;
public record OpenSettingsUiMessage : MessageBase;
public record OpenLightfinderSettingsMessage : MessageBase;
public enum PerformanceSettingsSection
{
TextureOptimization,
ModelOptimization,
}
public record OpenPerformanceSettingsMessage(PerformanceSettingsSection Section) : MessageBase;
public record DalamudLoginMessage : MessageBase;
public record DalamudLogoutMessage : MessageBase;
public record ActorTrackedMessage(ActorObjectService.ActorDescriptor Descriptor) : SameThreadMessage;

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Utils;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
@@ -19,7 +20,7 @@ public sealed class ModelDecimationService
private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly TaskRegistry<string> _decimationDeduplicator = new();
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
@@ -44,14 +45,14 @@ public sealed class ModelDecimationService
return;
}
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash))
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _decimationDeduplicator.TryGetExisting(hash, out _))
{
return;
}
_logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () =>
_decimationDeduplicator.GetOrStart(hash, async () =>
{
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try
@@ -66,9 +67,8 @@ public sealed class ModelDecimationService
finally
{
_decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}, CancellationToken.None);
});
}
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
@@ -116,7 +116,7 @@ public sealed class ModelDecimationService
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
if (_decimationDeduplicator.TryGetExisting(hash, out var job))
{
pending.Add(job);
}
@@ -139,13 +139,18 @@ public sealed class ModelDecimationService
return Task.CompletedTask;
}
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio))
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio, out var normalizeTangents))
{
_logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash);
return Task.CompletedTask;
}
_logger.LogInformation("Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##})", hash, triangleThreshold, targetRatio);
_logger.LogInformation(
"Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##}, normalize tangents {NormalizeTangents})",
hash,
triangleThreshold,
targetRatio,
normalizeTangents);
var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination))
@@ -154,7 +159,7 @@ public sealed class ModelDecimationService
return Task.CompletedTask;
}
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger))
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, normalizeTangents, _logger))
{
_failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash);
@@ -313,10 +318,11 @@ public sealed class ModelDecimationService
private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant();
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio)
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio, out bool normalizeTangents)
{
triangleThreshold = 15_000;
targetRatio = 0.8;
normalizeTangents = true;
var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation)
@@ -326,6 +332,7 @@ public sealed class ModelDecimationService
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio;
normalizeTangents = config.ModelDecimationNormalizeTangents;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{
return false;

View File

@@ -2,6 +2,7 @@ using LightlessSync.Interop.Ipc;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Penumbra.Api.Enums;
using System.Globalization;
namespace LightlessSync.Services.TextureCompression;
@@ -27,7 +28,9 @@ public sealed class TextureCompressionService
public async Task ConvertTexturesAsync(
IReadOnlyList<TextureCompressionRequest> requests,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw = true,
bool includeMipMaps = true)
{
if (requests.Count == 0)
{
@@ -48,7 +51,7 @@ public sealed class TextureCompressionService
continue;
}
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token).ConfigureAwait(false);
await RunPenumbraConversionAsync(request, textureType, total, completed, progress, token, requestRedraw, includeMipMaps).ConfigureAwait(false);
completed++;
}
@@ -65,14 +68,16 @@ public sealed class TextureCompressionService
int total,
int completedBefore,
IProgress<TextureConversionProgress>? progress,
CancellationToken token)
CancellationToken token,
bool requestRedraw,
bool includeMipMaps)
{
var primaryPath = request.PrimaryFilePath;
var displayJob = new TextureConversionJob(
primaryPath,
primaryPath,
targetType,
IncludeMipMaps: true,
IncludeMipMaps: includeMipMaps,
request.DuplicateFilePaths);
var backupPath = CreateBackupCopy(primaryPath);
@@ -83,7 +88,7 @@ public sealed class TextureCompressionService
try
{
WaitForAccess(primaryPath);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token).ConfigureAwait(false);
await _ipcManager.Penumbra.ConvertTextureFiles(_logger, new[] { conversionJob }, null, token, requestRedraw).ConfigureAwait(false);
if (!IsValidConversionResult(displayJob.OutputFile))
{
@@ -128,19 +133,46 @@ public sealed class TextureCompressionService
var cacheEntries = _fileCacheManager.GetFileCachesByPaths(paths.ToArray());
foreach (var path in paths)
{
var hasExpectedHash = TryGetExpectedHashFromPath(path, out var expectedHash);
if (!cacheEntries.TryGetValue(path, out var entry) || entry is null)
{
entry = _fileCacheManager.CreateFileEntry(path);
if (hasExpectedHash)
{
entry = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
}
entry ??= _fileCacheManager.CreateFileEntry(path);
if (entry is null)
{
_logger.LogWarning("Unable to locate cache entry for {Path}; skipping hash refresh", path);
continue;
}
}
else if (hasExpectedHash && entry.IsCacheEntry && !string.Equals(entry.Hash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Fixing cache hash mismatch for {Path}: {Current} -> {Expected}", path, entry.Hash, expectedHash);
_fileCacheManager.RemoveHashedFile(entry.Hash, entry.PrefixedFilePath, removeDerivedFiles: false);
var corrected = _fileCacheManager.CreateCacheEntryWithKnownHash(path, expectedHash);
if (corrected is not null)
{
entry = corrected;
}
}
try
{
_fileCacheManager.UpdateHashedFile(entry);
if (entry.IsCacheEntry)
{
var info = new FileInfo(path);
entry.Size = info.Length;
entry.CompressedSize = null;
entry.LastModifiedDateTicks = info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture);
_fileCacheManager.UpdateHashedFile(entry, computeProperties: false);
}
else
{
_fileCacheManager.UpdateHashedFile(entry);
}
}
catch (Exception ex)
{
@@ -149,6 +181,35 @@ public sealed class TextureCompressionService
}
}
private static bool TryGetExpectedHashFromPath(string path, out string hash)
{
hash = Path.GetFileNameWithoutExtension(path);
if (string.IsNullOrWhiteSpace(hash))
{
return false;
}
if (hash.Length is not (40 or 64))
{
return false;
}
for (var i = 0; i < hash.Length; i++)
{
var c = hash[i];
var isHex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!isHex)
{
return false;
}
}
hash = hash.ToUpperInvariant();
return true;
}
private static readonly string WorkingDirectory =
Path.Combine(Path.GetTempPath(), "LightlessSync.TextureCompression");

View File

@@ -4,9 +4,11 @@ using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using OtterTex;
using OtterImage = OtterTex.Image;
using LightlessSync.LightlessConfiguration;
using LightlessSync.Utils;
using LightlessSync.FileCache;
using Microsoft.Extensions.Logging;
using Lumina.Data.Files;
@@ -30,10 +32,12 @@ public sealed class TextureDownscaleService
private readonly LightlessConfigService _configService;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly FileCacheManager _fileCacheManager;
private readonly TextureCompressionService _textureCompressionService;
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly TaskRegistry<string> _downscaleDeduplicator = new();
private readonly ConcurrentDictionary<string, string> _downscaledPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _downscaleSemaphore = new(4);
private readonly SemaphoreSlim _compressionSemaphore = new(1);
private static readonly IReadOnlyDictionary<int, TextureCompressionTarget> BlockCompressedFormatMap =
new Dictionary<int, TextureCompressionTarget>
{
@@ -68,12 +72,14 @@ public sealed class TextureDownscaleService
ILogger<TextureDownscaleService> logger,
LightlessConfigService configService,
PlayerPerformanceConfigService playerPerformanceConfigService,
FileCacheManager fileCacheManager)
FileCacheManager fileCacheManager,
TextureCompressionService textureCompressionService)
{
_logger = logger;
_configService = configService;
_playerPerformanceConfigService = playerPerformanceConfigService;
_fileCacheManager = fileCacheManager;
_textureCompressionService = textureCompressionService;
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
@@ -82,9 +88,9 @@ public sealed class TextureDownscaleService
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
if (_downscaleDeduplicator.TryGetExisting(hash, out _)) return;
_activeJobs[hash] = Task.Run(async () =>
_downscaleDeduplicator.GetOrStart(hash, async () =>
{
TextureMapKind mapKind;
try
@@ -98,7 +104,7 @@ public sealed class TextureDownscaleService
}
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
});
}
public bool ShouldScheduleDownscale(string filePath)
@@ -107,7 +113,9 @@ public sealed class TextureDownscaleService
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale;
return performanceConfig.EnableNonIndexTextureMipTrim
|| performanceConfig.EnableIndexTextureDownscale
|| performanceConfig.EnableUncompressedTextureCompression;
}
public string GetPreferredPath(string hash, string originalPath)
@@ -144,7 +152,7 @@ public sealed class TextureDownscaleService
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
if (_downscaleDeduplicator.TryGetExisting(hash, out var job))
{
pending.Add(job);
}
@@ -182,10 +190,18 @@ public sealed class TextureDownscaleService
targetMaxDimension = ResolveTargetMaxDimension();
onlyDownscaleUncompressed = performanceConfig.OnlyDownscaleUncompressedTextures;
if (onlyDownscaleUncompressed && !headerInfo.HasValue)
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for texture {Hash}; format unknown and only-uncompressed enabled.", hash);
return;
}
destination = Path.Combine(GetDownscaledDirectory(), $"{hash}.tex");
if (File.Exists(destination))
{
RegisterDownscaledTexture(hash, sourcePath, destination);
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -196,6 +212,7 @@ public sealed class TextureDownscaleService
if (performanceConfig.EnableNonIndexTextureMipTrim
&& await TryDropTopMipAsync(hash, sourcePath, destination, targetMaxDimension, onlyDownscaleUncompressed, headerInfo).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
@@ -206,6 +223,7 @@ public sealed class TextureDownscaleService
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for non-index texture {Hash}; no mip reduction required.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -213,6 +231,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; feature disabled.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -222,6 +241,7 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; header dimensions {Width}x{Height} within target.", hash, headerValue.Width, headerValue.Height);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
@@ -229,10 +249,12 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; block compressed format {Format}.", hash, headerInfo.Value.Format);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var sourceScratch = TexFileHelper.Load(sourcePath);
var sourceFormat = sourceScratch.Meta.Format;
using var rgbaScratch = sourceScratch.GetRGBA(out var rgbaInfo).ThrowIfError(rgbaInfo);
var bytesPerPixel = rgbaInfo.Meta.Format.BitsPerPixel() / 8;
@@ -248,16 +270,39 @@ public sealed class TextureDownscaleService
{
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace("Skipping downscale for index texture {Hash}; already within bounds.", hash);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using var resized = IndexDownscaler.Downscale(originalImage, targetSize.width, targetSize.height, BlockMultiple);
var canReencodeWithPenumbra = TryResolveCompressionTarget(headerInfo, sourceFormat, out var compressionTarget);
using var resizedScratch = CreateScratchImage(resized, targetSize.width, targetSize.height);
using var finalScratch = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
if (!TryConvertForSave(resizedScratch, sourceFormat, out var finalScratch, canReencodeWithPenumbra))
{
if (canReencodeWithPenumbra
&& await TryReencodeWithPenumbraAsync(hash, sourcePath, destination, resizedScratch, compressionTarget).ConfigureAwait(false))
{
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
return;
}
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
_downscaledPaths[hash] = sourcePath;
_logger.LogTrace(
"Skipping downscale for index texture {Hash}; failed to re-encode to {Format}.",
hash,
sourceFormat);
await TryAutoCompressAsync(hash, sourcePath, mapKind, headerInfo).ConfigureAwait(false);
return;
}
using (finalScratch)
{
TexFileHelper.Save(destination, finalScratch);
RegisterDownscaledTexture(hash, sourcePath, destination);
}
await TryAutoCompressAsync(hash, destination, mapKind, null).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -277,7 +322,6 @@ public sealed class TextureDownscaleService
finally
{
_downscaleSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}
@@ -330,6 +374,157 @@ public sealed class TextureDownscaleService
}
}
private bool TryConvertForSave(
ScratchImage source,
DXGIFormat sourceFormat,
out ScratchImage result,
bool attemptPenumbraFallback)
{
var isCompressed = sourceFormat.IsCompressed();
var targetFormat = isCompressed ? sourceFormat : DXGIFormat.B8G8R8A8UNorm;
try
{
result = source.Convert(targetFormat);
return true;
}
catch (Exception ex)
{
var compressedFallback = attemptPenumbraFallback
? " Attempting Penumbra re-encode."
: " Skipping downscale.";
_logger.LogWarning(
ex,
"Failed to convert downscaled texture to {Format}.{Fallback}",
targetFormat,
isCompressed ? compressedFallback : " Falling back to B8G8R8A8.");
if (isCompressed)
{
result = default!;
return false;
}
result = source.Convert(DXGIFormat.B8G8R8A8UNorm);
return true;
}
}
private bool TryResolveCompressionTarget(TexHeaderInfo? headerInfo, DXGIFormat sourceFormat, out TextureCompressionTarget target)
{
if (headerInfo is { } info && TryGetCompressionTarget(info.Format, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
if (sourceFormat.IsCompressed() && BlockCompressedFormatMap.TryGetValue((int)sourceFormat, out target))
{
return _textureCompressionService.IsTargetSelectable(target);
}
target = default;
return false;
}
private async Task<bool> TryReencodeWithPenumbraAsync(
string hash,
string sourcePath,
string destination,
ScratchImage resizedScratch,
TextureCompressionTarget target)
{
try
{
using var uncompressed = resizedScratch.Convert(DXGIFormat.B8G8R8A8UNorm);
TexFileHelper.Save(destination, uncompressed);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save uncompressed downscaled texture for {Hash}. Skipping downscale.", hash);
TryDelete(destination);
return false;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var request = new TextureCompressionRequest(destination, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to re-encode downscaled texture {Hash} to {Target}. Skipping downscale.", hash, target);
TryDelete(destination);
return false;
}
finally
{
_compressionSemaphore.Release();
}
RegisterDownscaledTexture(hash, sourcePath, destination);
_logger.LogDebug("Downscaled texture {Hash} -> {Path} (re-encoded via Penumbra).", hash, destination);
return true;
}
private async Task TryAutoCompressAsync(string hash, string texturePath, TextureMapKind mapKind, TexHeaderInfo? headerInfo)
{
var performanceConfig = _playerPerformanceConfigService.Current;
if (!performanceConfig.EnableUncompressedTextureCompression)
{
return;
}
if (string.IsNullOrEmpty(texturePath) || !File.Exists(texturePath))
{
return;
}
var info = headerInfo ?? (TryReadTexHeader(texturePath, out var header) ? header : (TexHeaderInfo?)null);
if (!info.HasValue)
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; unable to read header.", hash);
return;
}
if (IsBlockCompressedFormat(info.Value.Format))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; already block-compressed.", hash);
return;
}
var suggestion = TextureMetadataHelper.GetSuggestedTarget(info.Value.Format.ToString(), mapKind, texturePath);
if (suggestion is null)
{
return;
}
var target = _textureCompressionService.NormalizeTarget(suggestion.Value.Target);
if (!_textureCompressionService.IsTargetSelectable(target))
{
_logger.LogTrace("Skipping auto-compress for texture {Hash}; target {Target} not supported.", hash, target);
return;
}
await _compressionSemaphore.WaitAsync().ConfigureAwait(false);
try
{
var includeMipMaps = !performanceConfig.SkipUncompressedTextureCompressionMipMaps;
var request = new TextureCompressionRequest(texturePath, Array.Empty<string>(), target);
await _textureCompressionService
.ConvertTexturesAsync(new[] { request }, null, CancellationToken.None, requestRedraw: false, includeMipMaps: includeMipMaps)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Auto-compress failed for texture {Hash} ({Path})", hash, texturePath);
}
finally
{
_compressionSemaphore.Release();
}
}
private static bool IsIndexMap(TextureMapKind kind)
=> kind is TextureMapKind.Mask
or TextureMapKind.Index;

View File

@@ -13,16 +13,20 @@ namespace LightlessSync.Services;
public sealed class UiService : DisposableMediatorSubscriberBase
{
private readonly List<WindowMediatorSubscriberBase> _createdWindows = [];
private readonly List<WindowMediatorSubscriberBase> _registeredWindows = [];
private readonly HashSet<WindowMediatorSubscriberBase> _uiHiddenWindows = [];
private readonly IUiBuilder _uiBuilder;
private readonly FileDialogManager _fileDialogManager;
private readonly ILogger<UiService> _logger;
private readonly LightlessConfigService _lightlessConfigService;
private readonly DalamudUtilService _dalamudUtilService;
private readonly WindowSystem _windowSystem;
private readonly UiFactory _uiFactory;
private readonly PairFactory _pairFactory;
private bool _uiHideActive;
public UiService(ILogger<UiService> logger, IUiBuilder uiBuilder,
LightlessConfigService lightlessConfigService, WindowSystem windowSystem,
LightlessConfigService lightlessConfigService, DalamudUtilService dalamudUtilService, WindowSystem windowSystem,
IEnumerable<WindowMediatorSubscriberBase> windows,
UiFactory uiFactory, FileDialogManager fileDialogManager,
LightlessMediator lightlessMediator, PairFactory pairFactory) : base(logger, lightlessMediator)
@@ -31,6 +35,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
_logger.LogTrace("Creating {type}", GetType().Name);
_uiBuilder = uiBuilder;
_lightlessConfigService = lightlessConfigService;
_dalamudUtilService = dalamudUtilService;
_windowSystem = windowSystem;
_uiFactory = uiFactory;
_pairFactory = pairFactory;
@@ -43,6 +48,7 @@ public sealed class UiService : DisposableMediatorSubscriberBase
foreach (var window in windows)
{
_registeredWindows.Add(window);
_windowSystem.AddWindow(window);
}
@@ -176,6 +182,8 @@ public sealed class UiService : DisposableMediatorSubscriberBase
{
_windowSystem.RemoveWindow(msg.Window);
_createdWindows.Remove(msg.Window);
_registeredWindows.Remove(msg.Window);
_uiHiddenWindows.Remove(msg.Window);
msg.Window.Dispose();
});
}
@@ -219,12 +227,72 @@ public sealed class UiService : DisposableMediatorSubscriberBase
MainStyle.PushStyle();
try
{
var hideOtherUi = ShouldHideOtherUi();
UpdateUiHideState(hideOtherUi);
_windowSystem.Draw();
_fileDialogManager.Draw();
if (!hideOtherUi)
_fileDialogManager.Draw();
}
finally
{
MainStyle.PopStyle();
}
}
}
private bool ShouldHideOtherUi()
{
var config = _lightlessConfigService.Current;
if (!config.ShowUiWhenUiHidden && _dalamudUtilService.IsGameUiHidden)
return true;
if (!config.ShowUiInGpose && _dalamudUtilService.IsInGpose)
return true;
return false;
}
private void UpdateUiHideState(bool hideOtherUi)
{
if (!hideOtherUi)
{
if (_uiHideActive)
{
foreach (var window in _uiHiddenWindows)
{
window.IsOpen = true;
}
_uiHiddenWindows.Clear();
_uiHideActive = false;
}
return;
}
_uiHideActive = true;
foreach (var window in EnumerateManagedWindows())
{
if (window is ZoneChatUi)
continue;
if (!window.IsOpen)
continue;
_uiHiddenWindows.Add(window);
window.IsOpen = false;
}
}
private IEnumerable<WindowMediatorSubscriberBase> EnumerateManagedWindows()
{
foreach (var window in _registeredWindows)
{
yield return window;
}
foreach (var window in _createdWindows)
{
yield return window;
}
}
}