using System.Numerics; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.Housing; using FFXIVClientStructs.FFXIV.Client.Game.Object; using FFXIVClientStructs.FFXIV.Client.Game.UI; using FFXIVClientStructs.FFXIV.Client.Graphics.Environment; using FFXIVClientStructs.FFXIV.Client.System.Framework; using FFXIVClientStructs.FFXIV.Client.UI.Agent; using Lumina.Excel.GeneratedSheets; using Newtonsoft.Json; using Screenie.Util; using Map = Lumina.Excel.GeneratedSheets.Map; using ObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind; namespace Screenie; [Serializable] public class SavedMetadata { public required string Blake3Hash; public required string Path; public required ScreenshotMetadata Metadata; } [Serializable] public class ScreenshotMetadata { public required Character? ActiveCharacter; public required string? Location; public required string? LocationSub; public required string? Area; public required string? AreaSub; public required uint TerritoryType; public required string? World; public required uint WorldId; public required DateTime CapturedAtLocal; public required DateTime CapturedAtUtc; public required string EorzeaTime; public required string? Weather; public required uint? Ward; public required uint? Plot; public required Character[] VisibleCharacters; public required PenumbraMod[] ModsInUse; internal static ScreenshotMetadata Capture(Plugin plugin) { EorzeaTime eorzea; uint? ward; uint? plot; short offsetX; short offsetY; float scale; Weather? weather; Map? map; PlaceName? area; PlaceName? areaSub; unsafe { var framework = Framework.Instance(); eorzea = new EorzeaTime((ulong) framework->ClientTime.EorzeaTime); var housing = HousingManager.Instance(); try { ward = (uint) housing->GetCurrentWard() + 1; } catch { ward = null; } try { plot = (uint) housing->GetCurrentPlot() + 1; } catch { plot = null; } var env = EnvManager.Instance(); var weatherId = env->ActiveWeather; weather = plugin.DataManager.GetExcelSheet()?.GetRow(weatherId); var agentMap = AgentMap.Instance(); offsetX = agentMap->CurrentOffsetX; offsetY = agentMap->CurrentOffsetY; scale = agentMap->CurrentMapSizeFactorFloat; var mapId = agentMap->CurrentMapId; map = plugin.DataManager.GetExcelSheet()?.GetRow(mapId); var territoryInfo = TerritoryInfo.Instance(); area = plugin.DataManager.GetExcelSheet()?.GetRow(territoryInfo->AreaPlaceNameID); areaSub = plugin.DataManager.GetExcelSheet()?.GetRow(territoryInfo->SubAreaPlaceNameID); } var territory = plugin.DataManager.GetExcelSheet()?.GetRow(plugin.ClientState.TerritoryType); Character? active = null; World? world = null; if (plugin.ClientState.LocalPlayer is { } player) { world = plugin.DataManager.GetExcelSheet()?.GetRow(player.CurrentWorld.Id); plugin.GameGui.WorldToScreen(player.Position, out var screenPos); active = new Character(player, screenPos, scale, offsetX, offsetY); } var timeUtc = DateTime.UtcNow; // var relevantModObjects = new List(); var relevantModObjects = new List(); var visible = plugin.ObjectTable .Select((obj, idx) => (obj, idx)) .Where(tuple => { var (obj, idx) = tuple; // pull a sneaky and populate the relevantModObjects list here if ( obj.ObjectKind is ObjectKind.Player or ObjectKind.Companion or ObjectKind.BattleNpc or ObjectKind.EventNpc or ObjectKind.MountType or ObjectKind.Retainer ) { if (idx > ushort.MaxValue) { Plugin.Log.Warning($"cannot pass object with idx {idx} to Penumbra: too large"); } else { unsafe { var gobj = (GameObject*) obj.Address; var draw = gobj->DrawObject; if (draw != null && draw->IsVisible) { var visible = plugin.GameGui.WorldToScreen(obj.Position, out _, out _); if (visible) { relevantModObjects.Add((ushort) idx); } } } } } return obj is PlayerCharacter; }) .Select(tuple => tuple.obj) .Cast() .Where(chara => { unsafe { var obj = (GameObject*) chara.Address; var draw = obj->DrawObject; return draw != null && draw->IsVisible; } }) .Select(chara => { var visible = plugin.GameGui.WorldToScreen(chara.Position, out var screenPos, out _); return (chara, screenPos, visible); }) .Where(tuple => tuple.visible) .Select(tuple => new Character(tuple.chara, tuple.screenPos, scale, offsetX, offsetY)) .ToArray(); var modDir = plugin.Penumbra.GetModDirectory(); var paths = plugin.Penumbra.GetGameObjectResourcePaths([.. relevantModObjects]); var mods = plugin.Penumbra.GetMods(); var modsInUse = new HashSet(); if (modDir != null && paths != null && mods != null) { var baseUri = new Uri($"{Path.TrimEndingDirectorySeparator(modDir)}/"); var enumerable = paths .Where(p => p != null) .SelectMany(p => p!.Values); foreach (var dict in paths) { if (dict == null) { continue; } foreach (var path in dict.Keys) { if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var pathUri)) { continue; } if (!pathUri.IsAbsoluteUri || !baseUri.IsBaseOf(pathUri)) { continue; } var modPathUntrimmed = Uri.UnescapeDataString(pathUri.Segments[baseUri.Segments.Length..][0]); var modPath = Path.TrimEndingDirectorySeparator(modPathUntrimmed); var found = mods.FirstOrDefault(mod => string.Equals(mod.Directory, modPath, StringComparison.OrdinalIgnoreCase)); if (found != default) { modsInUse.Add(new PenumbraMod { Name = found.Name, Path = found.Directory, }); } } } } return new ScreenshotMetadata { ActiveCharacter = active, Location = map?.PlaceName.Value?.Name.ToDalamudString().TextValue.WhitespaceToNull() ?? territory?.PlaceName.Value?.Name.ToDalamudString().TextValue.WhitespaceToNull(), LocationSub = map?.PlaceNameSub.Value?.Name.ToDalamudString().TextValue.WhitespaceToNull(), Area = area?.Name.ToDalamudString().TextValue.WhitespaceToNull(), AreaSub = areaSub?.Name.ToDalamudString().TextValue.WhitespaceToNull(), TerritoryType = plugin.ClientState.TerritoryType, World = world?.Name.ToDalamudString().TextValue.WhitespaceToNull(), WorldId = world?.RowId ?? 0, CapturedAtLocal = timeUtc.ToLocalTime(), CapturedAtUtc = timeUtc, EorzeaTime = $"{eorzea.Hour:00}:{eorzea.Minute:00}", Weather = weather?.Name.ToDalamudString().TextValue.WhitespaceToNull(), Ward = ward, Plot = plot, VisibleCharacters = visible, ModsInUse = [.. modsInUse.OrderBy(mod => mod.Name)], }; } } [Serializable] public class Character { public string Name; public string? HomeWorld; public uint HomeWorldId; public Vector3 RawPosition; public Vector3 MapPosition; public Vector2 ImagePosition; public uint Level; public string? Job; public uint JobId; public Character(PlayerCharacter player, Vector2 screenPos, float scale, short offsetX, short offsetY) { this.Name = player.Name.TextValue; this.HomeWorld = player.HomeWorld.GameData?.Name.ToDalamudString().TextValue.WhitespaceToNull(); this.HomeWorldId = player.HomeWorld.Id; this.RawPosition = player.Position; this.MapPosition = new Vector3( ConvertRawPositionToMapCoordinate(player.Position.X, scale, offsetX), ConvertRawPositionToMapCoordinate(player.Position.Z, scale, offsetY), player.Position.Y // TODO: how does map Z coord work ); this.ImagePosition = screenPos; this.Level = player.Level; this.Job = player.ClassJob.GameData?.Name.ToDalamudString().TextValue.WhitespaceToNull(); this.JobId = player.ClassJob.Id; } [JsonConstructor] #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private Character() { } #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. private static float ConvertRawPositionToMapCoordinate(float pos, float scale, short offset) { var num2 = (pos + offset) * scale; return (float) (41.0 / scale * ((num2 + 1024.0) / 2048.0) + 1.0); } } public struct EorzeaTime { public readonly ulong Year; public readonly ulong Month; public readonly ulong Day; public readonly ulong Hour; public readonly ulong Minute; public readonly ulong Second; private const double EorzeaTimeConst = (double) 3600 / 175; private const double YearConst = 33177600; private const double MonthConst = 2764800; private const double DayConst = 86400; private const double HourConst = 3600; private const double MinuteConst = 60; private const double SecondConst = 1; public EorzeaTime(DateTime time) : this((ulong) Math.Floor(((DateTimeOffset) time).ToUnixTimeSeconds() * EorzeaTimeConst)) { } public EorzeaTime(ulong eorzea) { this.Year = (ulong) Math.Floor(eorzea / YearConst) + 1; this.Month = (ulong) Math.Floor(eorzea / MonthConst % 12) + 1; this.Day = (ulong) Math.Floor(eorzea / DayConst % 32) + 1; this.Hour = (ulong) Math.Floor(eorzea / HourConst % 24); this.Minute = (ulong) Math.Floor(eorzea / MinuteConst % 60); this.Second = (ulong) Math.Floor(eorzea / SecondConst % 60); } } [Serializable] public class PenumbraMod { public required string Path { get; init; } public required string Name { get; init; } protected bool Equals(PenumbraMod other) { return this.Path == other.Path && this.Name == other.Name; } public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) { return false; } if (ReferenceEquals(this, obj)) { return true; } return obj.GetType() == this.GetType() && this.Equals((PenumbraMod) obj); } public override int GetHashCode() { unchecked { return (this.Path.GetHashCode() * 397) ^ this.Name.GetHashCode(); } } }