diff --git a/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs
new file mode 100644
index 00000000..dd966542
--- /dev/null
+++ b/Penumbra.CrashHandler/Buffers/AnimationInvocationBuffer.cs
@@ -0,0 +1,119 @@
+using System.Text.Json.Nodes;
+
+namespace Penumbra.CrashHandler.Buffers;
+
+/// The types of currently hooked and relevant animation loading functions.
+public enum AnimationInvocationType : int
+{
+ PapLoad,
+ ActionLoad,
+ ScheduleClipUpdate,
+ LoadTimelineResources,
+ LoadCharacterVfx,
+ LoadCharacterSound,
+ ApricotSoundPlay,
+ LoadAreaVfx,
+ CharacterBaseLoadAnimation,
+}
+
+/// The full crash entry for an invoked vfx function.
+public record struct VfxFuncInvokedEntry(
+ double Age,
+ DateTimeOffset Timestamp,
+ int ThreadId,
+ string InvocationType,
+ string CharacterName,
+ string CharacterAddress,
+ string CollectionName) : ICrashDataEntry;
+
+/// Only expose the write interface for the buffer.
+public interface IAnimationInvocationBufferWriter
+{
+ /// Write a line into the buffer with the given data.
+ /// The address of the related character, if known.
+ /// The name of the related character, anonymized or relying on index if unavailable, if known.
+ /// The name of the associated collection. Not anonymized.
+ /// The type of VFX func called.
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName, AnimationInvocationType type);
+}
+
+internal sealed class AnimationInvocationBuffer : MemoryMappedBuffer, IAnimationInvocationBufferWriter, IBufferReader
+{
+ private const int _version = 1;
+ private const int _lineCount = 64;
+ private const int _lineCapacity = 256;
+ private const string _name = "Penumbra.AnimationInvocation";
+
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName, AnimationInvocationType type)
+ {
+ var accessor = GetCurrentLineLocking();
+ lock (accessor)
+ {
+ accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ accessor.Write(8, Environment.CurrentManagedThreadId);
+ accessor.Write(12, (int)type);
+ accessor.Write(16, characterAddress);
+ var span = GetSpan(accessor, 24, 104);
+ WriteSpan(characterName, span);
+ span = GetSpan(accessor, 128);
+ WriteString(collectionName, span);
+ }
+ }
+
+ public uint TotalCount
+ => TotalWrittenLines;
+
+ public IEnumerable GetLines(DateTimeOffset crashTime)
+ {
+ var lineCount = (int)CurrentLineCount;
+ for (var i = lineCount - 1; i >= 0; --i)
+ {
+ var line = GetLine(i);
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line));
+ var thread = BitConverter.ToInt32(line[8..]);
+ var type = (AnimationInvocationType)BitConverter.ToInt32(line[12..]);
+ var address = BitConverter.ToUInt64(line[16..]);
+ var characterName = ReadString(line[24..]);
+ var collectionName = ReadString(line[128..]);
+ yield return new JsonObject()
+ {
+ [nameof(VfxFuncInvokedEntry.Age)] = (crashTime - timestamp).TotalSeconds,
+ [nameof(VfxFuncInvokedEntry.Timestamp)] = timestamp,
+ [nameof(VfxFuncInvokedEntry.ThreadId)] = thread,
+ [nameof(VfxFuncInvokedEntry.InvocationType)] = ToName(type),
+ [nameof(VfxFuncInvokedEntry.CharacterName)] = characterName,
+ [nameof(VfxFuncInvokedEntry.CharacterAddress)] = address.ToString("X"),
+ [nameof(VfxFuncInvokedEntry.CollectionName)] = collectionName,
+ };
+ }
+ }
+
+ public static IBufferReader CreateReader()
+ => new AnimationInvocationBuffer(false);
+
+ public static IAnimationInvocationBufferWriter CreateWriter()
+ => new AnimationInvocationBuffer();
+
+ private AnimationInvocationBuffer(bool writer)
+ : base(_name, _version)
+ { }
+
+ private AnimationInvocationBuffer()
+ : base(_name, _version, _lineCount, _lineCapacity)
+ { }
+
+ private static string ToName(AnimationInvocationType type)
+ => type switch
+ {
+ AnimationInvocationType.PapLoad => "PAP Load",
+ AnimationInvocationType.ActionLoad => "Action Load",
+ AnimationInvocationType.ScheduleClipUpdate => "Schedule Clip Update",
+ AnimationInvocationType.LoadTimelineResources => "Load Timeline Resources",
+ AnimationInvocationType.LoadCharacterVfx => "Load Character VFX",
+ AnimationInvocationType.LoadCharacterSound => "Load Character Sound",
+ AnimationInvocationType.ApricotSoundPlay => "Apricot Sound Play",
+ AnimationInvocationType.LoadAreaVfx => "Load Area VFX",
+ AnimationInvocationType.CharacterBaseLoadAnimation => "Load Animation (CharacterBase)",
+ _ => $"Unknown ({(int)type})",
+ };
+}
diff --git a/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs
new file mode 100644
index 00000000..1fe5d7ba
--- /dev/null
+++ b/Penumbra.CrashHandler/Buffers/CharacterBaseBuffer.cs
@@ -0,0 +1,85 @@
+using System.Text.Json.Nodes;
+
+namespace Penumbra.CrashHandler.Buffers;
+
+/// Only expose the write interface for the buffer.
+public interface ICharacterBaseBufferWriter
+{
+ /// Write a line into the buffer with the given data.
+ /// The address of the related character, if known.
+ /// The name of the related character, anonymized or relying on index if unavailable, if known.
+ /// The name of the associated collection. Not anonymized.
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName);
+}
+
+/// The full crash entry for a loaded character base.
+public record struct CharacterLoadedEntry(
+ double Age,
+ DateTimeOffset Timestamp,
+ int ThreadId,
+ string CharacterName,
+ string CharacterAddress,
+ string CollectionName) : ICrashDataEntry;
+
+internal sealed class CharacterBaseBuffer : MemoryMappedBuffer, ICharacterBaseBufferWriter, IBufferReader
+{
+ private const int _version = 1;
+ private const int _lineCount = 10;
+ private const int _lineCapacity = 256;
+ private const string _name = "Penumbra.CharacterBase";
+
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName)
+ {
+ var accessor = GetCurrentLineLocking();
+ lock (accessor)
+ {
+ accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ accessor.Write(8, Environment.CurrentManagedThreadId);
+ accessor.Write(12, characterAddress);
+ var span = GetSpan(accessor, 20, 108);
+ WriteSpan(characterName, span);
+ span = GetSpan(accessor, 128);
+ WriteString(collectionName, span);
+ }
+ }
+
+ public IEnumerable GetLines(DateTimeOffset crashTime)
+ {
+ var lineCount = (int)CurrentLineCount;
+ for (var i = lineCount - 1; i >= 0; --i)
+ {
+ var line = GetLine(i);
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line));
+ var thread = BitConverter.ToInt32(line[8..]);
+ var address = BitConverter.ToUInt64(line[12..]);
+ var characterName = ReadString(line[20..]);
+ var collectionName = ReadString(line[128..]);
+ yield return new JsonObject
+ {
+ [nameof(CharacterLoadedEntry.Age)] = (crashTime - timestamp).TotalSeconds,
+ [nameof(CharacterLoadedEntry.Timestamp)] = timestamp,
+ [nameof(CharacterLoadedEntry.ThreadId)] = thread,
+ [nameof(CharacterLoadedEntry.CharacterName)] = characterName,
+ [nameof(CharacterLoadedEntry.CharacterAddress)] = address.ToString("X"),
+ [nameof(CharacterLoadedEntry.CollectionName)] = collectionName,
+ };
+ }
+ }
+
+ public uint TotalCount
+ => TotalWrittenLines;
+
+ public static IBufferReader CreateReader()
+ => new CharacterBaseBuffer(false);
+
+ public static ICharacterBaseBufferWriter CreateWriter()
+ => new CharacterBaseBuffer();
+
+ private CharacterBaseBuffer(bool writer)
+ : base(_name, _version)
+ { }
+
+ private CharacterBaseBuffer()
+ : base(_name, _version, _lineCount, _lineCapacity)
+ { }
+}
diff --git a/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs
new file mode 100644
index 00000000..35055864
--- /dev/null
+++ b/Penumbra.CrashHandler/Buffers/MemoryMappedBuffer.cs
@@ -0,0 +1,215 @@
+using System.Diagnostics.CodeAnalysis;
+using System.IO.MemoryMappedFiles;
+using System.Numerics;
+using System.Text;
+
+namespace Penumbra.CrashHandler.Buffers;
+
+public class MemoryMappedBuffer : IDisposable
+{
+ private const int MinHeaderLength = 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4;
+
+ private readonly MemoryMappedFile _file;
+ private readonly MemoryMappedViewAccessor _header;
+ private readonly MemoryMappedViewAccessor[] _lines;
+
+ public readonly int Version;
+ public readonly uint LineCount;
+ public readonly uint LineCapacity;
+ private readonly uint _lineMask;
+ private bool _disposed;
+
+ protected uint CurrentLineCount
+ {
+ get => _header.ReadUInt32(16);
+ set => _header.Write(16, value);
+ }
+
+ protected uint CurrentLinePosition
+ {
+ get => _header.ReadUInt32(20);
+ set => _header.Write(20, value);
+ }
+
+ public uint TotalWrittenLines
+ {
+ get => _header.ReadUInt32(24);
+ protected set => _header.Write(24, value);
+ }
+
+ public MemoryMappedBuffer(string mapName, int version, uint lineCount, uint lineCapacity)
+ {
+ Version = version;
+ LineCount = BitOperations.RoundUpToPowerOf2(Math.Clamp(lineCount, 2, int.MaxValue >> 3));
+ LineCapacity = BitOperations.RoundUpToPowerOf2(Math.Clamp(lineCapacity, 2, int.MaxValue >> 3));
+ _lineMask = LineCount - 1;
+ var fileName = Encoding.UTF8.GetBytes(mapName);
+ var headerLength = (uint)(4 + 4 + 4 + 4 + 4 + 4 + 4 + fileName.Length + 1);
+ headerLength = (headerLength & 0b111) > 0 ? (headerLength & ~0b111u) + 0b1000 : headerLength;
+ var capacity = LineCount * LineCapacity + headerLength;
+ _file = MemoryMappedFile.CreateNew(mapName, capacity, MemoryMappedFileAccess.ReadWrite, MemoryMappedFileOptions.None,
+ HandleInheritability.Inheritable);
+ _header = _file.CreateViewAccessor(0, headerLength);
+ _header.Write(0, headerLength);
+ _header.Write(4, Version);
+ _header.Write(8, LineCount);
+ _header.Write(12, LineCapacity);
+ _header.WriteArray(28, fileName, 0, fileName.Length);
+ _header.Write(fileName.Length + 28, (byte)0);
+ _lines = Enumerable.Range(0, (int)LineCount).Select(i
+ => _file.CreateViewAccessor(headerLength + i * LineCapacity, LineCapacity, MemoryMappedFileAccess.ReadWrite))
+ .ToArray();
+ }
+
+ public MemoryMappedBuffer(string mapName, int? expectedVersion = null, uint? expectedMinLineCount = null,
+ uint? expectedMinLineCapacity = null)
+ {
+ _file = MemoryMappedFile.OpenExisting(mapName, MemoryMappedFileRights.ReadWrite, HandleInheritability.Inheritable);
+ using var headerLine = _file.CreateViewAccessor(0, 4, MemoryMappedFileAccess.Read);
+ var headerLength = headerLine.ReadUInt32(0);
+ if (headerLength < MinHeaderLength)
+ Throw($"Map {mapName} did not contain a valid header.");
+
+ _header = _file.CreateViewAccessor(0, headerLength, MemoryMappedFileAccess.ReadWrite);
+ Version = _header.ReadInt32(4);
+ LineCount = _header.ReadUInt32(8);
+ LineCapacity = _header.ReadUInt32(12);
+ _lineMask = LineCount - 1;
+ if (expectedVersion.HasValue && expectedVersion.Value != Version)
+ Throw($"Map {mapName} has version {Version} instead of {expectedVersion.Value}.");
+
+ if (LineCount < expectedMinLineCount)
+ Throw($"Map {mapName} has line count {LineCount} but line count >= {expectedMinLineCount.Value} is required.");
+
+ if (LineCapacity < expectedMinLineCapacity)
+ Throw($"Map {mapName} has line capacity {LineCapacity} but line capacity >= {expectedMinLineCapacity.Value} is required.");
+
+ var name = ReadString(GetSpan(_header, 28));
+ if (name != mapName)
+ Throw($"Map {mapName} does not contain its map name at the expected location.");
+
+ _lines = Enumerable.Range(0, (int)LineCount).Select(i
+ => _file.CreateViewAccessor(headerLength + i * LineCapacity, LineCapacity, MemoryMappedFileAccess.ReadWrite))
+ .ToArray();
+
+ [DoesNotReturn]
+ void Throw(string text)
+ {
+ _file.Dispose();
+ _disposed = true;
+ throw new Exception(text);
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ _disposed = true;
+ }
+
+ protected static string ReadString(Span span)
+ {
+ if (span.IsEmpty)
+ throw new Exception("String from empty span requested.");
+
+ var termination = span.IndexOf((byte)0);
+ if (termination < 0)
+ throw new Exception("String in span is not terminated.");
+
+ return Encoding.UTF8.GetString(span[..termination]);
+ }
+
+ protected static int WriteString(string text, Span span)
+ {
+ var bytes = Encoding.UTF8.GetBytes(text);
+ var length = bytes.Length + 1;
+ if (length > span.Length)
+ throw new Exception($"String {text} is too long to write into span.");
+
+ bytes.CopyTo(span);
+ span[bytes.Length] = 0;
+ return length;
+ }
+
+ protected static int WriteSpan(ReadOnlySpan input, Span span)
+ {
+ var length = input.Length + 1;
+ if (length > span.Length)
+ throw new Exception("Byte array is too long to write into span.");
+
+ input.CopyTo(span);
+ span[input.Length] = 0;
+ return length;
+ }
+
+ protected Span GetLine(int i)
+ {
+ if (i < 0 || i > LineCount)
+ return null;
+
+ lock (_header)
+ {
+ var lineIdx = CurrentLinePosition + i & _lineMask;
+ if (lineIdx > CurrentLineCount)
+ return null;
+
+ return GetSpan(_lines[lineIdx]);
+ }
+ }
+
+
+ protected MemoryMappedViewAccessor GetCurrentLineLocking()
+ {
+ MemoryMappedViewAccessor view;
+ lock (_header)
+ {
+ var currentLineCount = CurrentLineCount;
+ if (currentLineCount == LineCount)
+ {
+ var currentLinePos = CurrentLinePosition;
+ view = _lines[currentLinePos]!;
+ CurrentLinePosition = currentLinePos + 1 & _lineMask;
+ }
+ else
+ {
+ view = _lines[currentLineCount];
+ ++CurrentLineCount;
+ }
+
+ ++TotalWrittenLines;
+ _header.Flush();
+ }
+
+ return view;
+ }
+
+ protected static Span GetSpan(MemoryMappedViewAccessor accessor, int offset = 0)
+ => GetSpan(accessor, offset, (int)accessor.Capacity - offset);
+
+ protected static unsafe Span GetSpan(MemoryMappedViewAccessor accessor, int offset, int size)
+ {
+ byte* ptr = null;
+ accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
+ size = Math.Min(size, (int)accessor.Capacity - offset);
+ if (size < 0)
+ return [];
+
+ var span = new Span(ptr + offset + accessor.PointerOffset, size);
+ return span;
+ }
+
+ protected void Dispose(bool disposing)
+ {
+ if (_disposed)
+ return;
+
+ _header.Dispose();
+ foreach (var line in _lines)
+ line.Dispose();
+ _file.Dispose();
+ }
+
+ ~MemoryMappedBuffer()
+ => Dispose(false);
+}
diff --git a/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs
new file mode 100644
index 00000000..b472d413
--- /dev/null
+++ b/Penumbra.CrashHandler/Buffers/ModdedFileBuffer.cs
@@ -0,0 +1,99 @@
+using System.Text.Json.Nodes;
+
+namespace Penumbra.CrashHandler.Buffers;
+
+/// Only expose the write interface for the buffer.
+public interface IModdedFileBufferWriter
+{
+ /// Write a line into the buffer with the given data.
+ /// The address of the related character, if known.
+ /// The name of the related character, anonymized or relying on index if unavailable, if known.
+ /// The name of the associated collection. Not anonymized.
+ /// The file name as requested by the game.
+ /// The actual modded file name loaded.
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName, ReadOnlySpan requestedFileName,
+ ReadOnlySpan actualFileName);
+}
+
+/// The full crash entry for a loaded modded file.
+public record struct ModdedFileLoadedEntry(
+ double Age,
+ DateTimeOffset Timestamp,
+ int ThreadId,
+ string CharacterName,
+ string CharacterAddress,
+ string CollectionName,
+ string RequestedFileName,
+ string ActualFileName) : ICrashDataEntry;
+
+internal sealed class ModdedFileBuffer : MemoryMappedBuffer, IModdedFileBufferWriter, IBufferReader
+{
+ private const int _version = 1;
+ private const int _lineCount = 128;
+ private const int _lineCapacity = 1024;
+ private const string _name = "Penumbra.ModdedFile";
+
+ public void WriteLine(nint characterAddress, ReadOnlySpan characterName, string collectionName, ReadOnlySpan requestedFileName,
+ ReadOnlySpan actualFileName)
+ {
+ var accessor = GetCurrentLineLocking();
+ lock (accessor)
+ {
+ accessor.Write(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
+ accessor.Write(8, Environment.CurrentManagedThreadId);
+ accessor.Write(12, characterAddress);
+ var span = GetSpan(accessor, 20, 80);
+ WriteSpan(characterName, span);
+ span = GetSpan(accessor, 92, 80);
+ WriteString(collectionName, span);
+ span = GetSpan(accessor, 172, 260);
+ WriteSpan(requestedFileName, span);
+ span = GetSpan(accessor, 432);
+ WriteSpan(actualFileName, span);
+ }
+ }
+
+ public uint TotalCount
+ => TotalWrittenLines;
+
+ public IEnumerable GetLines(DateTimeOffset crashTime)
+ {
+ var lineCount = (int)CurrentLineCount;
+ for (var i = lineCount - 1; i >= 0; --i)
+ {
+ var line = GetLine(i);
+ var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(BitConverter.ToInt64(line));
+ var thread = BitConverter.ToInt32(line[8..]);
+ var address = BitConverter.ToUInt64(line[12..]);
+ var characterName = ReadString(line[20..]);
+ var collectionName = ReadString(line[92..]);
+ var requestedFileName = ReadString(line[172..]);
+ var actualFileName = ReadString(line[432..]);
+ yield return new JsonObject()
+ {
+ [nameof(ModdedFileLoadedEntry.Age)] = (crashTime - timestamp).TotalSeconds,
+ [nameof(ModdedFileLoadedEntry.Timestamp)] = timestamp,
+ [nameof(ModdedFileLoadedEntry.ThreadId)] = thread,
+ [nameof(ModdedFileLoadedEntry.CharacterName)] = characterName,
+ [nameof(ModdedFileLoadedEntry.CharacterAddress)] = address.ToString("X"),
+ [nameof(ModdedFileLoadedEntry.CollectionName)] = collectionName,
+ [nameof(ModdedFileLoadedEntry.RequestedFileName)] = requestedFileName,
+ [nameof(ModdedFileLoadedEntry.ActualFileName)] = actualFileName,
+ };
+ }
+ }
+
+ public static IBufferReader CreateReader()
+ => new ModdedFileBuffer(false);
+
+ public static IModdedFileBufferWriter CreateWriter()
+ => new ModdedFileBuffer();
+
+ private ModdedFileBuffer(bool writer)
+ : base(_name, _version)
+ { }
+
+ private ModdedFileBuffer()
+ : base(_name, _version, _lineCount, _lineCapacity)
+ { }
+}
diff --git a/Penumbra.CrashHandler/CrashData.cs b/Penumbra.CrashHandler/CrashData.cs
new file mode 100644
index 00000000..956a3db7
--- /dev/null
+++ b/Penumbra.CrashHandler/CrashData.cs
@@ -0,0 +1,62 @@
+using Penumbra.CrashHandler.Buffers;
+
+namespace Penumbra.CrashHandler;
+
+/// A base entry for crash data.
+public interface ICrashDataEntry
+{
+ /// The timestamp of the event.
+ DateTimeOffset Timestamp { get; }
+
+ /// The thread invoking the event.
+ int ThreadId { get; }
+
+ /// The age of the event compared to the crash. (Redundantly with the timestamp)
+ double Age { get; }
+}
+
+/// A full set of crash data.
+public class CrashData
+{
+ /// The mode this data was obtained - manually or from a crash.
+ public string Mode { get; set; } = "Unknown";
+
+ /// The time this crash data was generated.
+ public DateTimeOffset CrashTime { get; set; } = DateTimeOffset.UnixEpoch;
+
+ /// The FFXIV process ID when this data was generated.
+ public int ProcessId { get; set; } = 0;
+
+ /// The FFXIV Exit Code (if any) when this data was generated.
+ public int ExitCode { get; set; } = 0;
+
+ /// The total amount of characters loaded during this session.
+ public int TotalCharactersLoaded { get; set; } = 0;
+
+ /// The total amount of modded files loaded during this session.
+ public int TotalModdedFilesLoaded { get; set; } = 0;
+
+ /// The total amount of vfx functions invoked during this session.
+ public int TotalVFXFuncsInvoked { get; set; } = 0;
+
+ /// The last character loaded before this crash data was generated.
+ public CharacterLoadedEntry? LastCharacterLoaded
+ => LastCharactersLoaded.Count == 0 ? default : LastCharactersLoaded[0];
+
+ /// The last modded file loaded before this crash data was generated.
+ public ModdedFileLoadedEntry? LastModdedFileLoaded
+ => LastModdedFilesLoaded.Count == 0 ? default : LastModdedFilesLoaded[0];
+
+ /// The last vfx function invoked before this crash data was generated.
+ public VfxFuncInvokedEntry? LastVfxFuncInvoked
+ => LastVfxFuncsInvoked.Count == 0 ? default : LastVfxFuncsInvoked[0];
+
+ /// A collection of the last few characters loaded before this crash data was generated.
+ public List LastCharactersLoaded { get; } = [];
+
+ /// A collection of the last few modded files loaded before this crash data was generated.
+ public List LastModdedFilesLoaded { get; } = [];
+
+ /// A collection of the last few vfx functions invoked before this crash data was generated.
+ public List LastVfxFuncsInvoked { get; } = [];
+}
diff --git a/Penumbra.CrashHandler/GameEventLogReader.cs b/Penumbra.CrashHandler/GameEventLogReader.cs
new file mode 100644
index 00000000..283be526
--- /dev/null
+++ b/Penumbra.CrashHandler/GameEventLogReader.cs
@@ -0,0 +1,53 @@
+using System.Text.Json.Nodes;
+using Penumbra.CrashHandler.Buffers;
+
+namespace Penumbra.CrashHandler;
+
+public interface IBufferReader
+{
+ public uint TotalCount { get; }
+ public IEnumerable GetLines(DateTimeOffset crashTime);
+}
+
+public sealed class GameEventLogReader : IDisposable
+{
+ public readonly (IBufferReader Reader, string TypeSingular, string TypePlural)[] Readers =
+ [
+ (CharacterBaseBuffer.CreateReader(), "CharacterLoaded", "CharactersLoaded"),
+ (ModdedFileBuffer.CreateReader(), "ModdedFileLoaded", "ModdedFilesLoaded"),
+ (AnimationInvocationBuffer.CreateReader(), "VFXFuncInvoked", "VFXFuncsInvoked"),
+ ];
+
+ public void Dispose()
+ {
+ foreach (var (reader, _, _) in Readers)
+ (reader as IDisposable)?.Dispose();
+ }
+
+
+ public JsonObject Dump(string mode, int processId, int exitCode)
+ {
+ var crashTime = DateTimeOffset.UtcNow;
+ var obj = new JsonObject
+ {
+ [nameof(CrashData.Mode)] = mode,
+ [nameof(CrashData.CrashTime)] = DateTimeOffset.UtcNow,
+ [nameof(CrashData.ProcessId)] = processId,
+ [nameof(CrashData.ExitCode)] = exitCode,
+ };
+
+ foreach (var (reader, singular, _) in Readers)
+ obj["Last" + singular] = reader.GetLines(crashTime).FirstOrDefault();
+
+ foreach (var (reader, _, plural) in Readers)
+ {
+ obj["Total" + plural] = reader.TotalCount;
+ var array = new JsonArray();
+ foreach (var file in reader.GetLines(crashTime))
+ array.Add(file);
+ obj["Last" + plural] = array;
+ }
+
+ return obj;
+ }
+}
diff --git a/Penumbra.CrashHandler/GameEventLogWriter.cs b/Penumbra.CrashHandler/GameEventLogWriter.cs
new file mode 100644
index 00000000..8e809cec
--- /dev/null
+++ b/Penumbra.CrashHandler/GameEventLogWriter.cs
@@ -0,0 +1,17 @@
+using Penumbra.CrashHandler.Buffers;
+
+namespace Penumbra.CrashHandler;
+
+public sealed class GameEventLogWriter : IDisposable
+{
+ public readonly ICharacterBaseBufferWriter CharacterBase = CharacterBaseBuffer.CreateWriter();
+ public readonly IModdedFileBufferWriter FileLoaded = ModdedFileBuffer.CreateWriter();
+ public readonly IAnimationInvocationBufferWriter AnimationFuncInvoked = AnimationInvocationBuffer.CreateWriter();
+
+ public void Dispose()
+ {
+ (CharacterBase as IDisposable)?.Dispose();
+ (FileLoaded as IDisposable)?.Dispose();
+ (AnimationFuncInvoked as IDisposable)?.Dispose();
+ }
+}
diff --git a/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj
new file mode 100644
index 00000000..ea61b968
--- /dev/null
+++ b/Penumbra.CrashHandler/Penumbra.CrashHandler.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net7.0-windows
+ preview
+ enable
+ x64
+ enable
+ true
+ false
+
+
+
+ $(appdata)\XIVLauncher\addon\Hooks\dev\
+ $(HOME)/.xlcore/dalamud/Hooks/dev/
+ $(DALAMUD_HOME)/
+
+
+
+ embedded
+
+
+
+ embedded
+
+
+
diff --git a/Penumbra.CrashHandler/Program.cs b/Penumbra.CrashHandler/Program.cs
new file mode 100644
index 00000000..e4a46348
--- /dev/null
+++ b/Penumbra.CrashHandler/Program.cs
@@ -0,0 +1,30 @@
+using System.Diagnostics;
+using System.Text.Json;
+
+namespace Penumbra.CrashHandler;
+
+public class CrashHandler
+{
+ public static void Main(string[] args)
+ {
+ if (args.Length < 2 || !int.TryParse(args[1], out var pid))
+ return;
+
+ try
+ {
+ using var reader = new GameEventLogReader();
+ var parent = Process.GetProcessById(pid);
+
+ parent.WaitForExit();
+ var exitCode = parent.ExitCode;
+ var obj = reader.Dump("Crash", pid, exitCode);
+ using var fs = File.Open(args[0], FileMode.Create);
+ using var w = new Utf8JsonWriter(fs, new JsonWriterOptions { Indented = true });
+ obj.WriteTo(w, new JsonSerializerOptions() { WriteIndented = true });
+ }
+ catch (Exception ex)
+ {
+ File.WriteAllText(args[0], $"{DateTime.UtcNow} {pid} {ex}");
+ }
+ }
+}
diff --git a/Penumbra.sln b/Penumbra.sln
index 5c11aaea..78fa1543 100644
--- a/Penumbra.sln
+++ b/Penumbra.sln
@@ -18,6 +18,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.Api", "Penumbra.Ap
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Penumbra.String", "Penumbra.String\Penumbra.String.csproj", "{5549BAFD-6357-4B1A-800C-75AC36E5B76D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Penumbra.CrashHandler", "Penumbra.CrashHandler\Penumbra.CrashHandler.csproj", "{EE834491-A98F-4395-BE0D-6861AE5AD953}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -44,6 +46,10 @@ Global
{5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5549BAFD-6357-4B1A-800C-75AC36E5B76D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EE834491-A98F-4395-BE0D-6861AE5AD953}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EE834491-A98F-4395-BE0D-6861AE5AD953}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Penumbra/Communication/CreatingCharacterBase.cs b/Penumbra/Communication/CreatingCharacterBase.cs
index 1e232761..2f249c14 100644
--- a/Penumbra/Communication/CreatingCharacterBase.cs
+++ b/Penumbra/Communication/CreatingCharacterBase.cs
@@ -1,5 +1,6 @@
using OtterGui.Classes;
using Penumbra.Api;
+using Penumbra.Services;
namespace Penumbra.Communication;
@@ -19,5 +20,8 @@ public sealed class CreatingCharacterBase()
{
///
Api = 0,
+
+ ///
+ CrashHandler = 0,
}
}
diff --git a/Penumbra/Configuration.cs b/Penumbra/Configuration.cs
index 188be65d..9c0b4f2d 100644
--- a/Penumbra/Configuration.cs
+++ b/Penumbra/Configuration.cs
@@ -33,6 +33,7 @@ public class Configuration : IPluginConfiguration, ISavable
public string ModDirectory { get; set; } = string.Empty;
public string ExportDirectory { get; set; } = string.Empty;
+ public bool UseCrashHandler { get; set; } = true;
public bool OpenWindowAtStart { get; set; } = false;
public bool HideUiInGPose { get; set; } = false;
public bool HideUiInCutscenes { get; set; } = true;
diff --git a/Penumbra/EphemeralConfig.cs b/Penumbra/EphemeralConfig.cs
index 98b1a5d6..0a542d04 100644
--- a/Penumbra/EphemeralConfig.cs
+++ b/Penumbra/EphemeralConfig.cs
@@ -47,7 +47,7 @@ public class EphemeralConfig : ISavable, IDisposable
///
public EphemeralConfig(SaveService saveService, ModPathChanged modPathChanged)
{
- _saveService = saveService;
+ _saveService = saveService;
_modPathChanged = modPathChanged;
Load();
_modPathChanged.Subscribe(OnModPathChanged, ModPathChanged.Priority.EphemeralConfig);
@@ -94,13 +94,13 @@ public class EphemeralConfig : ISavable, IDisposable
public void Save(StreamWriter writer)
{
- using var jWriter = new JsonTextWriter(writer);
+ using var jWriter = new JsonTextWriter(writer);
jWriter.Formatting = Formatting.Indented;
- var serializer = new JsonSerializer { Formatting = Formatting.Indented };
+ var serializer = new JsonSerializer { Formatting = Formatting.Indented };
serializer.Serialize(jWriter, this);
}
- /// Overwrite the last saved mod path if it changes.
+ /// Overwrite the last saved mod path if it changes.
private void OnModPathChanged(ModPathChangeType type, Mod mod, DirectoryInfo? old, DirectoryInfo? _)
{
if (type is not ModPathChangeType.Moved || !string.Equals(old?.Name, LastModPath, StringComparison.OrdinalIgnoreCase))
diff --git a/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs b/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs
index b91c5375..77927593 100644
--- a/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs
+++ b/Penumbra/Interop/Hooks/Animation/ApricotListenerSoundPlay.cs
@@ -2,21 +2,25 @@ using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using OtterGui.Services;
using Penumbra.Collections;
+using Penumbra.CrashHandler.Buffers;
using Penumbra.GameData;
using Penumbra.Interop.PathResolving;
+using Penumbra.Services;
namespace Penumbra.Interop.Hooks.Animation;
/// Called for some sound effects caused by animations or VFX.
public sealed unsafe class ApricotListenerSoundPlay : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly CrashHandlerService _crashHandler;
- public ApricotListenerSoundPlay(HookManager hooks, GameState state, CollectionResolver collectionResolver)
+ public ApricotListenerSoundPlay(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Apricot Listener Sound Play", Sigs.ApricotListenerSoundPlay, Detour, true);
}
@@ -46,6 +50,7 @@ public sealed unsafe class ApricotListenerSoundPlay : FastHook
public sealed unsafe class CharacterBaseLoadAnimation : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
- private readonly DrawObjectState _drawObjectState;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly DrawObjectState _drawObjectState;
+ private readonly CrashHandlerService _crashHandler;
public CharacterBaseLoadAnimation(HookManager hooks, GameState state, CollectionResolver collectionResolver,
- DrawObjectState drawObjectState)
+ DrawObjectState drawObjectState, CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
_drawObjectState = drawObjectState;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("CharacterBase Load Animation", Sigs.CharacterBaseLoadAnimation, Detour, true);
}
@@ -33,7 +37,9 @@ public sealed unsafe class CharacterBaseLoadAnimation : FastHook Load a ground-based area VFX.
public sealed unsafe class LoadAreaVfx : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly CrashHandlerService _crashHandler;
- public LoadAreaVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver)
+ public LoadAreaVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Load Area VFX", Sigs.LoadAreaVfx, Detour, true);
}
@@ -25,10 +29,11 @@ public sealed unsafe class LoadAreaVfx : FastHook
private nint Detour(uint vfxId, float* pos, GameObject* caster, float unk1, float unk2, byte unk3)
{
var newData = caster != null
- ? _collectionResolver.IdentifyCollection(caster, true)
- : ResolveData.Invalid;
+ ? _collectionResolver.IdentifyCollection(caster, true)
+ : ResolveData.Invalid;
var last = _state.SetAnimationData(newData);
+ _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.LoadAreaVfx);
var ret = Task.Result.Original(vfxId, pos, caster, unk1, unk2, unk3);
Penumbra.Log.Excessive(
$"[Load Area VFX] Invoked with {vfxId}, [{pos[0]} {pos[1]} {pos[2]}], 0x{(nint)caster:X}, {unk1}, {unk2}, {unk3} -> 0x{ret:X}.");
diff --git a/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs b/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs
index af13805d..98454a77 100644
--- a/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs
+++ b/Penumbra/Interop/Hooks/Animation/LoadCharacterSound.cs
@@ -1,19 +1,23 @@
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using OtterGui.Services;
+using Penumbra.CrashHandler.Buffers;
using Penumbra.Interop.PathResolving;
+using Penumbra.Services;
namespace Penumbra.Interop.Hooks.Animation;
/// Characters load some of their voice lines or whatever with this function.
public sealed unsafe class LoadCharacterSound : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly CrashHandlerService _crashHandler;
- public LoadCharacterSound(HookManager hooks, GameState state, CollectionResolver collectionResolver)
+ public LoadCharacterSound(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler)
{
- _state = state;
+ _state = state;
_collectionResolver = collectionResolver;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Load Character Sound",
(nint)FFXIVClientStructs.FFXIV.Client.Game.Character.Character.VfxContainer.MemberFunctionPointers.LoadCharacterSound, Detour,
true);
@@ -25,7 +29,9 @@ public sealed unsafe class LoadCharacterSound : FastHook {ret}.");
_state.RestoreSoundData(last);
diff --git a/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs b/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs
index 240c062e..69c22773 100644
--- a/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs
+++ b/Penumbra/Interop/Hooks/Animation/LoadCharacterVfx.cs
@@ -2,9 +2,11 @@ using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using OtterGui.Services;
using Penumbra.Collections;
+using Penumbra.CrashHandler.Buffers;
using Penumbra.GameData;
using Penumbra.Interop.PathResolving;
using Penumbra.Interop.Structs;
+using Penumbra.Services;
using Penumbra.String;
namespace Penumbra.Interop.Hooks.Animation;
@@ -12,15 +14,18 @@ namespace Penumbra.Interop.Hooks.Animation;
/// Load a VFX specifically for a character.
public sealed unsafe class LoadCharacterVfx : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
- private readonly IObjectTable _objects;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly IObjectTable _objects;
+ private readonly CrashHandlerService _crashHandler;
- public LoadCharacterVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects)
+ public LoadCharacterVfx(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects,
+ CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
_objects = objects;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Load Character VFX", Sigs.LoadCharacterVfx, Detour, true);
}
@@ -45,6 +50,7 @@ public sealed unsafe class LoadCharacterVfx : FastHookGameObjectId:X}, {vfxParams->TargetCount}, {unk1}, {unk2}, {unk3}, {unk4} -> 0x{ret:X}.");
diff --git a/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs
index 2ca8ffe7..ade957b9 100644
--- a/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs
+++ b/Penumbra/Interop/Hooks/Animation/LoadTimelineResources.cs
@@ -3,8 +3,10 @@ using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using OtterGui.Services;
using Penumbra.Collections;
+using Penumbra.CrashHandler;
using Penumbra.GameData;
using Penumbra.Interop.PathResolving;
+using Penumbra.Services;
namespace Penumbra.Interop.Hooks.Animation;
@@ -14,19 +16,21 @@ namespace Penumbra.Interop.Hooks.Animation;
///
public sealed unsafe class LoadTimelineResources : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
- private readonly ICondition _conditions;
- private readonly IObjectTable _objects;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly ICondition _conditions;
+ private readonly IObjectTable _objects;
+ private readonly CrashHandlerService _crashHandler;
public LoadTimelineResources(HookManager hooks, GameState state, CollectionResolver collectionResolver, ICondition conditions,
- IObjectTable objects)
+ IObjectTable objects, CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
_conditions = conditions;
_objects = objects;
- Task = hooks.CreateHook("Load Timeline Resources", Sigs.LoadTimelineResources, Detour, true);
+ _crashHandler = crashHandler;
+ Task = hooks.CreateHook("Load Timeline Resources", Sigs.LoadTimelineResources, Detour, true);
}
public delegate ulong Delegate(nint timeline);
@@ -39,7 +43,13 @@ public sealed unsafe class LoadTimelineResources : FastHook Called when some action timelines update.
public sealed unsafe class ScheduleClipUpdate : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
- private readonly IObjectTable _objects;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly IObjectTable _objects;
+ private readonly CrashHandlerService _crashHandler;
- public ScheduleClipUpdate(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects)
+ public ScheduleClipUpdate(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects,
+ CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
_objects = objects;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Schedule Clip Update", Sigs.ScheduleClipUpdate, Detour, true);
}
@@ -27,8 +32,9 @@ public sealed unsafe class ScheduleClipUpdate : FastHookSchedulerTimeline));
+ var newData = LoadTimelineResources.GetDataFromTimeline(_objects, _collectionResolver, clipScheduler->SchedulerTimeline);
+ var last = _state.SetAnimationData(newData);
+ _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.ScheduleClipUpdate);
Task.Result.Original(clipScheduler);
_state.RestoreAnimationData(last);
}
diff --git a/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs b/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs
index 48931d73..6de3aeb0 100644
--- a/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs
+++ b/Penumbra/Interop/Hooks/Animation/SomeActionLoad.cs
@@ -1,21 +1,25 @@
-using FFXIVClientStructs.FFXIV.Client.Game;
-using FFXIVClientStructs.FFXIV.Client.Game.Object;
-using OtterGui.Services;
-using Penumbra.GameData;
+using FFXIVClientStructs.FFXIV.Client.Game;
+using FFXIVClientStructs.FFXIV.Client.Game.Object;
+using OtterGui.Services;
+using Penumbra.CrashHandler.Buffers;
+using Penumbra.GameData;
using Penumbra.Interop.PathResolving;
+using Penumbra.Services;
+
+namespace Penumbra.Interop.Hooks.Animation;
-namespace Penumbra.Interop.Hooks.Animation;
-
/// Seems to load character actions when zoning or changing class, maybe.
public sealed unsafe class SomeActionLoad : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly CrashHandlerService _crashHandler;
- public SomeActionLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver)
+ public SomeActionLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver, CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Some Action Load", Sigs.LoadSomeAction, Detour, true);
}
@@ -24,8 +28,10 @@ public sealed unsafe class SomeActionLoad : FastHook
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
private void Detour(ActionTimelineManager* timelineManager)
{
- var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection((GameObject*)timelineManager->Parent, true));
+ var newData = _collectionResolver.IdentifyCollection((GameObject*)timelineManager->Parent, true);
+ var last = _state.SetAnimationData(newData);
Penumbra.Log.Excessive($"[Some Action Load] Invoked on 0x{(nint)timelineManager:X}.");
+ _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.ActionLoad);
Task.Result.Original(timelineManager);
_state.RestoreAnimationData(last);
}
diff --git a/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs b/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs
index 75caacee..3e60e62f 100644
--- a/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs
+++ b/Penumbra/Interop/Hooks/Animation/SomePapLoad.cs
@@ -1,23 +1,28 @@
using Dalamud.Plugin.Services;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using OtterGui.Services;
+using Penumbra.CrashHandler.Buffers;
using Penumbra.GameData;
using Penumbra.Interop.PathResolving;
+using Penumbra.Services;
namespace Penumbra.Interop.Hooks.Animation;
/// Unknown what exactly this is, but it seems to load a bunch of paps.
public sealed unsafe class SomePapLoad : FastHook
{
- private readonly GameState _state;
- private readonly CollectionResolver _collectionResolver;
- private readonly IObjectTable _objects;
+ private readonly GameState _state;
+ private readonly CollectionResolver _collectionResolver;
+ private readonly IObjectTable _objects;
+ private readonly CrashHandlerService _crashHandler;
- public SomePapLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects)
+ public SomePapLoad(HookManager hooks, GameState state, CollectionResolver collectionResolver, IObjectTable objects,
+ CrashHandlerService crashHandler)
{
_state = state;
_collectionResolver = collectionResolver;
_objects = objects;
+ _crashHandler = crashHandler;
Task = hooks.CreateHook("Some PAP Load", Sigs.LoadSomePap, Detour, true);
}
@@ -33,8 +38,9 @@ public sealed unsafe class SomePapLoad : FastHook
var actorIdx = (int)(*(*(ulong**)timelinePtr + 1) >> 3);
if (actorIdx >= 0 && actorIdx < _objects.Length)
{
- var last = _state.SetAnimationData(_collectionResolver.IdentifyCollection((GameObject*)_objects.GetObjectAddress(actorIdx),
- true));
+ var newData = _collectionResolver.IdentifyCollection((GameObject*)_objects.GetObjectAddress(actorIdx), true);
+ var last = _state.SetAnimationData(newData);
+ _crashHandler.LogAnimation(newData.AssociatedGameObject, newData.ModCollection, AnimationInvocationType.PapLoad);
Task.Result.Original(a1, a2, a3, a4);
_state.RestoreAnimationData(last);
return;
diff --git a/Penumbra/Penumbra.csproj b/Penumbra/Penumbra.csproj
index 01b3d680..796eae01 100644
--- a/Penumbra/Penumbra.csproj
+++ b/Penumbra/Penumbra.csproj
@@ -1,4 +1,4 @@
-
+
net7.0-windows
preview
@@ -69,8 +69,7 @@
-
-
+
@@ -79,8 +78,10 @@
+
+
@@ -103,4 +104,4 @@
$(GitCommitHash)
-
\ No newline at end of file
+
diff --git a/Penumbra/Services/CrashHandlerService.cs b/Penumbra/Services/CrashHandlerService.cs
new file mode 100644
index 00000000..4e2bce0f
--- /dev/null
+++ b/Penumbra/Services/CrashHandlerService.cs
@@ -0,0 +1,296 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using FFXIVClientStructs.FFXIV.Client.Game.Object;
+using OtterGui.Services;
+using Penumbra.Collections;
+using Penumbra.Communication;
+using Penumbra.CrashHandler;
+using Penumbra.CrashHandler.Buffers;
+using Penumbra.GameData.Actors;
+using Penumbra.Interop.ResourceLoading;
+using Penumbra.Interop.Structs;
+using Penumbra.String;
+using Penumbra.String.Classes;
+using FileMode = System.IO.FileMode;
+
+namespace Penumbra.Services;
+
+public sealed class CrashHandlerService : IDisposable, IService
+{
+ private readonly FilenameService _files;
+ private readonly CommunicatorService _communicator;
+ private readonly ActorManager _actors;
+ private readonly ResourceLoader _resourceLoader;
+ private readonly Configuration _config;
+
+ public CrashHandlerService(FilenameService files, CommunicatorService communicator, ActorManager actors, ResourceLoader resourceLoader,
+ Configuration config)
+ {
+ _files = files;
+ _communicator = communicator;
+ _actors = actors;
+ _resourceLoader = resourceLoader;
+ _config = config;
+
+ if (!_config.UseCrashHandler)
+ return;
+
+ OpenEventWriter();
+ LaunchCrashHandler();
+ if (_eventWriter != null)
+ Subscribe();
+ }
+
+ public void Dispose()
+ {
+ CloseEventWriter();
+ _eventWriter?.Dispose();
+ if (_child != null)
+ {
+ _child.Kill();
+ Penumbra.Log.Debug($"Killed crash handler child process {_child.Id}.");
+ }
+
+ Unsubscribe();
+ }
+
+ private Process? _child;
+ private GameEventLogWriter? _eventWriter;
+
+ public string CopiedExe = string.Empty;
+
+ public string OriginalExe
+ => _files.CrashHandlerExe;
+
+ public string LogPath
+ => _files.LogFileName;
+
+ public int ChildProcessId
+ => _child?.Id ?? -1;
+
+ public int ProcessId
+ => Environment.ProcessId;
+
+ public bool IsRunning
+ => _eventWriter != null && _child is { HasExited: false };
+
+ public int ChildExitCode
+ => IsRunning ? 0 : _child?.ExitCode ?? 0;
+
+ public void Enable()
+ {
+ if (_config.UseCrashHandler)
+ return;
+
+ _config.UseCrashHandler = true;
+ _config.Save();
+ OpenEventWriter();
+ LaunchCrashHandler();
+ if (_eventWriter != null)
+ Subscribe();
+ }
+
+ public void Disable()
+ {
+ if (!_config.UseCrashHandler)
+ return;
+
+ _config.UseCrashHandler = false;
+ _config.Save();
+ CloseEventWriter();
+ CloseCrashHandler();
+ Unsubscribe();
+ }
+
+ public JsonObject? Load(string fileName)
+ {
+ if (!File.Exists(fileName))
+ return null;
+
+ try
+ {
+ var data = File.ReadAllText(fileName);
+ return JsonNode.Parse(data) as JsonObject;
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error($"Could not parse crash dump at {fileName}:\n{ex}");
+ return null;
+ }
+ }
+
+ public void CloseCrashHandler()
+ {
+ if (_child == null)
+ return;
+
+ try
+ {
+ if (_child.HasExited)
+ return;
+
+ _child.Kill();
+ Penumbra.Log.Debug($"Closed Crash Handler at {CopiedExe}.");
+ }
+ catch (Exception ex)
+ {
+ _child = null;
+ Penumbra.Log.Debug($"Closed not close Crash Handler at {CopiedExe}:\n{ex}.");
+ }
+ }
+
+ public void LaunchCrashHandler()
+ {
+ try
+ {
+ CloseCrashHandler();
+ CopiedExe = CopyExecutables();
+ var info = new ProcessStartInfo()
+ {
+ CreateNoWindow = true,
+ FileName = CopiedExe,
+ };
+ info.ArgumentList.Add(_files.LogFileName);
+ info.ArgumentList.Add(Environment.ProcessId.ToString());
+ _child = Process.Start(info);
+ if (_child == null)
+ throw new Exception("Child Process could not be created.");
+
+ Penumbra.Log.Information($"Opened Crash Handler at {CopiedExe}, PID {_child.Id}.");
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error($"Could not launch crash handler process:\n{ex}");
+ CloseCrashHandler();
+ _child = null;
+ }
+ }
+
+ public JsonObject? Dump()
+ {
+ if (_eventWriter == null)
+ return null;
+
+ try
+ {
+ using var reader = new GameEventLogReader();
+ JsonObject jObj;
+ lock (_eventWriter)
+ {
+ jObj = reader.Dump("Manual Dump", Environment.ProcessId, 0);
+ }
+
+ var logFile = _files.LogFileName;
+ using var s = File.Open(logFile, FileMode.Create);
+ using var jw = new Utf8JsonWriter(s, new JsonWriterOptions() { Indented = true });
+ jObj.WriteTo(jw);
+ Penumbra.Log.Information($"Dumped crash handler memory to {logFile}.");
+ return jObj;
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error($"Error dumping crash handler memory to file:\n{ex}");
+ return null;
+ }
+ }
+
+ private string CopyExecutables()
+ {
+ var parent = Path.GetDirectoryName(_files.CrashHandlerExe)!;
+ var folder = Path.Combine(parent, "temp");
+ Directory.CreateDirectory(folder);
+ foreach (var file in Directory.EnumerateFiles(parent, "Penumbra.CrashHandler.*"))
+ File.Copy(file, Path.Combine(folder, Path.GetFileName(file)), true);
+ return Path.Combine(folder, Path.GetFileName(_files.CrashHandlerExe));
+ }
+
+ public void LogAnimation(nint character, ModCollection collection, AnimationInvocationType type)
+ {
+ if (_eventWriter == null)
+ return;
+
+ var name = GetActorName(character);
+ lock (_eventWriter)
+ {
+ _eventWriter?.AnimationFuncInvoked.WriteLine(character, name.Span, collection.Name, type);
+ }
+ }
+
+ private void OnCreatingCharacterBase(nint address, string collection, nint _1, nint _2, nint _3)
+ {
+ if (_eventWriter == null)
+ return;
+
+ var name = GetActorName(address);
+
+ lock (_eventWriter)
+ {
+ _eventWriter?.CharacterBase.WriteLine(address, name.Span, collection);
+ }
+ }
+
+ private unsafe ByteString GetActorName(nint address)
+ {
+ var obj = (GameObject*)address;
+ if (obj == null)
+ return ByteString.FromSpanUnsafe("Unknown"u8, true, false, true);
+
+ var id = _actors.FromObject(obj, out _, false, true, false);
+ return id.IsValid ? ByteString.FromStringUnsafe(id.Incognito(null), false) :
+ obj->Name[0] != 0 ? new ByteString(obj->Name) : ByteString.FromStringUnsafe($"Actor #{obj->ObjectIndex}", false);
+ }
+
+ private unsafe void OnResourceLoaded(ResourceHandle* handle, Utf8GamePath originalPath, FullPath? manipulatedPath, ResolveData resolveData)
+ {
+ if (manipulatedPath == null || _eventWriter == null)
+ return;
+
+ var dashIdx = manipulatedPath.Value.InternalName[0] == (byte)'|' ? manipulatedPath.Value.InternalName.IndexOf((byte)'|', 1) : -1;
+ if (dashIdx >= 0 && !Utf8GamePath.IsRooted(manipulatedPath.Value.InternalName.Substring(dashIdx + 1)))
+ return;
+
+ var name = GetActorName(resolveData.AssociatedGameObject);
+ lock (_eventWriter)
+ {
+ _eventWriter!.FileLoaded.WriteLine(resolveData.AssociatedGameObject, name.Span, resolveData.ModCollection.Name,
+ manipulatedPath.Value.InternalName.Span, originalPath.Path.Span);
+ }
+ }
+
+ private void CloseEventWriter()
+ {
+ if (_eventWriter == null)
+ return;
+
+ _eventWriter.Dispose();
+ _eventWriter = null;
+ Penumbra.Log.Debug("Closed Event Writer for crash handler.");
+ }
+
+ private void OpenEventWriter()
+ {
+ try
+ {
+ CloseEventWriter();
+ _eventWriter = new GameEventLogWriter();
+ Penumbra.Log.Debug("Opened new Event Writer for crash handler.");
+ }
+ catch (Exception ex)
+ {
+ Penumbra.Log.Error($"Could not open Event Writer:\n{ex}");
+ CloseEventWriter();
+ }
+ }
+
+ private unsafe void Subscribe()
+ {
+ _communicator.CreatingCharacterBase.Subscribe(OnCreatingCharacterBase, CreatingCharacterBase.Priority.CrashHandler);
+ _resourceLoader.ResourceLoaded += OnResourceLoaded;
+ }
+
+ private unsafe void Unsubscribe()
+ {
+ _communicator.CreatingCharacterBase.Unsubscribe(OnCreatingCharacterBase);
+ _resourceLoader.ResourceLoaded -= OnResourceLoaded;
+ }
+}
diff --git a/Penumbra/Services/FilenameService.cs b/Penumbra/Services/FilenameService.cs
index 5f918a90..49b4cb42 100644
--- a/Penumbra/Services/FilenameService.cs
+++ b/Penumbra/Services/FilenameService.cs
@@ -15,6 +15,12 @@ public class FilenameService(DalamudPluginInterface pi) : IService
public readonly string FilesystemFile = Path.Combine(pi.ConfigDirectory.FullName, "sort_order.json");
public readonly string ActiveCollectionsFile = Path.Combine(pi.ConfigDirectory.FullName, "active_collections.json");
+ public readonly string CrashHandlerExe =
+ Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "Penumbra.CrashHandler.exe");
+
+ public readonly string LogFileName =
+ Path.Combine(Path.GetDirectoryName(Path.GetDirectoryName(pi.ConfigDirectory.FullName)!)!, "Penumbra.log");
+
/// Obtain the path of a collection file given its name.
public string CollectionFile(ModCollection collection)
=> CollectionFile(collection.Name);
diff --git a/Penumbra/UI/Tabs/ChangedItemsTab.cs b/Penumbra/UI/Tabs/ChangedItemsTab.cs
index 76fb8c96..ab0badf4 100644
--- a/Penumbra/UI/Tabs/ChangedItemsTab.cs
+++ b/Penumbra/UI/Tabs/ChangedItemsTab.cs
@@ -12,22 +12,13 @@ using Penumbra.UI.Classes;
namespace Penumbra.UI.Tabs;
-public class ChangedItemsTab : ITab
+public class ChangedItemsTab(
+ CollectionManager collectionManager,
+ CollectionSelectHeader collectionHeader,
+ ChangedItemDrawer drawer,
+ CommunicatorService communicator)
+ : ITab
{
- private readonly CollectionManager _collectionManager;
- private readonly ChangedItemDrawer _drawer;
- private readonly CollectionSelectHeader _collectionHeader;
- private readonly CommunicatorService _communicator;
-
- public ChangedItemsTab(CollectionManager collectionManager, CollectionSelectHeader collectionHeader, ChangedItemDrawer drawer,
- CommunicatorService communicator)
- {
- _collectionManager = collectionManager;
- _collectionHeader = collectionHeader;
- _drawer = drawer;
- _communicator = communicator;
- }
-
public ReadOnlySpan Label
=> "Changed Items"u8;
@@ -36,8 +27,8 @@ public class ChangedItemsTab : ITab
public void DrawContent()
{
- _collectionHeader.Draw(true);
- _drawer.DrawTypeFilter();
+ collectionHeader.Draw(true);
+ drawer.DrawTypeFilter();
var varWidth = DrawFilters();
using var child = ImRaii.Child("##changedItemsChild", -Vector2.One);
if (!child)
@@ -54,7 +45,7 @@ public class ChangedItemsTab : ITab
ImGui.TableSetupColumn("mods", flags, varWidth - 130 * UiHelpers.Scale);
ImGui.TableSetupColumn("id", flags, 130 * UiHelpers.Scale);
- var items = _collectionManager.Active.Current.ChangedItems;
+ var items = collectionManager.Active.Current.ChangedItems;
var rest = ImGuiClip.FilteredClippedDraw(items, skips, FilterChangedItem, DrawChangedItemColumn);
ImGuiClip.DrawEndDummy(rest, height);
}
@@ -75,21 +66,21 @@ public class ChangedItemsTab : ITab
/// Apply the current filters.
private bool FilterChangedItem(KeyValuePair, object?)> item)
- => _drawer.FilterChangedItem(item.Key, item.Value.Item2, _changedItemFilter)
+ => drawer.FilterChangedItem(item.Key, item.Value.Item2, _changedItemFilter)
&& (_changedItemModFilter.IsEmpty || item.Value.Item1.Any(m => m.Name.Contains(_changedItemModFilter)));
/// Draw a full column for a changed item.
private void DrawChangedItemColumn(KeyValuePair, object?)> item)
{
ImGui.TableNextColumn();
- _drawer.DrawCategoryIcon(item.Key, item.Value.Item2);
+ drawer.DrawCategoryIcon(item.Key, item.Value.Item2);
ImGui.SameLine();
- _drawer.DrawChangedItem(item.Key, item.Value.Item2);
+ drawer.DrawChangedItem(item.Key, item.Value.Item2);
ImGui.TableNextColumn();
DrawModColumn(item.Value.Item1);
ImGui.TableNextColumn();
- _drawer.DrawModelData(item.Value.Item2);
+ drawer.DrawModelData(item.Value.Item2);
}
private void DrawModColumn(SingleArray mods)
@@ -102,7 +93,7 @@ public class ChangedItemsTab : ITab
if (ImGui.Selectable(first.Name, false, ImGuiSelectableFlags.None, new Vector2(0, ImGui.GetFrameHeight()))
&& ImGui.GetIO().KeyCtrl
&& first is Mod mod)
- _communicator.SelectTab.Invoke(TabType.Mods, mod);
+ communicator.SelectTab.Invoke(TabType.Mods, mod);
if (ImGui.IsItemHovered())
{
diff --git a/Penumbra/UI/Tabs/ConfigTabBar.cs b/Penumbra/UI/Tabs/ConfigTabBar.cs
index ad3fdb3d..9fd07f27 100644
--- a/Penumbra/UI/Tabs/ConfigTabBar.cs
+++ b/Penumbra/UI/Tabs/ConfigTabBar.cs
@@ -44,8 +44,8 @@ public class ConfigTabBar : IDisposable
Watcher = watcher;
OnScreen = onScreen;
Messages = messages;
- Tabs = new ITab[]
- {
+ Tabs =
+ [
Settings,
Collections,
Mods,
@@ -56,7 +56,7 @@ public class ConfigTabBar : IDisposable
Resource,
Watcher,
Messages,
- };
+ ];
_communicator.SelectTab.Subscribe(OnSelectTab, Communication.SelectTab.Priority.ConfigTabBar);
}
diff --git a/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs
new file mode 100644
index 00000000..3d32d267
--- /dev/null
+++ b/Penumbra/UI/Tabs/Debug/CrashDataExtensions.cs
@@ -0,0 +1,104 @@
+using ImGuiNET;
+using OtterGui;
+using OtterGui.Raii;
+using Penumbra.CrashHandler;
+
+namespace Penumbra.UI.Tabs.Debug;
+
+public static class CrashDataExtensions
+{
+ public static void DrawMeta(this CrashData data)
+ {
+ using (ImRaii.Group())
+ {
+ ImGui.TextUnformatted(nameof(data.Mode));
+ ImGui.TextUnformatted(nameof(data.CrashTime));
+ ImGui.TextUnformatted(nameof(data.ExitCode));
+ ImGui.TextUnformatted(nameof(data.ProcessId));
+ ImGui.TextUnformatted(nameof(data.TotalModdedFilesLoaded));
+ ImGui.TextUnformatted(nameof(data.TotalCharactersLoaded));
+ ImGui.TextUnformatted(nameof(data.TotalVFXFuncsInvoked));
+ }
+
+ ImGui.SameLine();
+ using (ImRaii.Group())
+ {
+ ImGui.TextUnformatted(data.Mode);
+ ImGui.TextUnformatted(data.CrashTime.ToString());
+ ImGui.TextUnformatted(data.ExitCode.ToString());
+ ImGui.TextUnformatted(data.ProcessId.ToString());
+ ImGui.TextUnformatted(data.TotalModdedFilesLoaded.ToString());
+ ImGui.TextUnformatted(data.TotalCharactersLoaded.ToString());
+ ImGui.TextUnformatted(data.TotalVFXFuncsInvoked.ToString());
+ }
+ }
+
+ public static void DrawCharacters(this CrashData data)
+ {
+ using var tree = ImRaii.TreeNode("Last Characters");
+ if (!tree)
+ return;
+
+ using var table = ImRaii.Table("##characterTable", 6,
+ ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner);
+ if (!table)
+ return;
+
+ ImGuiClip.ClippedDraw(data.LastCharactersLoaded, character =>
+ {
+ ImGuiUtil.DrawTableColumn(character.Age.ToString(CultureInfo.InvariantCulture));
+ ImGuiUtil.DrawTableColumn(character.ThreadId.ToString());
+ ImGuiUtil.DrawTableColumn(character.CharacterName);
+ ImGuiUtil.DrawTableColumn(character.CollectionName);
+ ImGuiUtil.DrawTableColumn(character.CharacterAddress);
+ ImGuiUtil.DrawTableColumn(character.Timestamp.ToString());
+ }, ImGui.GetTextLineHeightWithSpacing());
+ }
+
+ public static void DrawFiles(this CrashData data)
+ {
+ using var tree = ImRaii.TreeNode("Last Files");
+ if (!tree)
+ return;
+
+ using var table = ImRaii.Table("##filesTable", 8,
+ ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner);
+ if (!table)
+ return;
+
+ ImGuiClip.ClippedDraw(data.LastModdedFilesLoaded, file =>
+ {
+ ImGuiUtil.DrawTableColumn(file.Age.ToString(CultureInfo.InvariantCulture));
+ ImGuiUtil.DrawTableColumn(file.ThreadId.ToString());
+ ImGuiUtil.DrawTableColumn(file.ActualFileName);
+ ImGuiUtil.DrawTableColumn(file.RequestedFileName);
+ ImGuiUtil.DrawTableColumn(file.CharacterName);
+ ImGuiUtil.DrawTableColumn(file.CollectionName);
+ ImGuiUtil.DrawTableColumn(file.CharacterAddress);
+ ImGuiUtil.DrawTableColumn(file.Timestamp.ToString());
+ }, ImGui.GetTextLineHeightWithSpacing());
+ }
+
+ public static void DrawVfxInvocations(this CrashData data)
+ {
+ using var tree = ImRaii.TreeNode("Last VFX Invocations");
+ if (!tree)
+ return;
+
+ using var table = ImRaii.Table("##vfxTable", 7,
+ ImGuiTableFlags.SizingFixedFit | ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner);
+ if (!table)
+ return;
+
+ ImGuiClip.ClippedDraw(data.LastVfxFuncsInvoked, vfx =>
+ {
+ ImGuiUtil.DrawTableColumn(vfx.Age.ToString(CultureInfo.InvariantCulture));
+ ImGuiUtil.DrawTableColumn(vfx.ThreadId.ToString());
+ ImGuiUtil.DrawTableColumn(vfx.InvocationType);
+ ImGuiUtil.DrawTableColumn(vfx.CharacterName);
+ ImGuiUtil.DrawTableColumn(vfx.CollectionName);
+ ImGuiUtil.DrawTableColumn(vfx.CharacterAddress);
+ ImGuiUtil.DrawTableColumn(vfx.Timestamp.ToString());
+ }, ImGui.GetTextLineHeightWithSpacing());
+ }
+}
diff --git a/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs
new file mode 100644
index 00000000..c8e7f001
--- /dev/null
+++ b/Penumbra/UI/Tabs/Debug/CrashHandlerPanel.cs
@@ -0,0 +1,136 @@
+using System.Text.Json;
+using Dalamud.Interface.DragDrop;
+using ImGuiNET;
+using OtterGui;
+using OtterGui.Raii;
+using OtterGui.Services;
+using Penumbra.CrashHandler;
+using Penumbra.Services;
+
+namespace Penumbra.UI.Tabs.Debug;
+
+public class CrashHandlerPanel(CrashHandlerService _service, Configuration _config, IDragDropManager _dragDrop) : IService
+{
+ private CrashData? _lastDump;
+ private string _lastLoadedFile = string.Empty;
+ private CrashData? _lastLoad;
+ private Exception? _lastLoadException;
+
+ public void Draw()
+ {
+ DrawDropSource();
+ DrawData();
+ DrawDropTarget();
+ }
+
+ private void DrawData()
+ {
+ using var _ = ImRaii.Group();
+ using var header = ImRaii.CollapsingHeader("Crash Handler");
+ if (!header)
+ return;
+
+ DrawButtons();
+ DrawMainData();
+ DrawObject("Last Manual Dump", _lastDump, null);
+ DrawObject(_lastLoadedFile.Length > 0 ? $"Loaded File ({_lastLoadedFile})###Loaded File" : "Loaded File", _lastLoad,
+ _lastLoadException);
+ }
+
+ private void DrawMainData()
+ {
+ using var table = ImRaii.Table("##CrashHandlerTable", 2, ImGuiTableFlags.SizingFixedFit);
+ if (!table)
+ return;
+
+ PrintValue("Enabled", _config.UseCrashHandler);
+ PrintValue("Copied Executable Path", _service.CopiedExe);
+ PrintValue("Original Executable Path", _service.OriginalExe);
+ PrintValue("Log File Path", _service.LogPath);
+ PrintValue("XIV Process ID", _service.ProcessId.ToString());
+ PrintValue("Crash Handler Running", _service.IsRunning.ToString());
+ PrintValue("Crash Handler Process ID", _service.ChildProcessId.ToString());
+ PrintValue("Crash Handler Exit Code", _service.ChildExitCode.ToString());
+ }
+
+ private void DrawButtons()
+ {
+ if (ImGui.Button("Dump Crash Handler Memory"))
+ _lastDump = _service.Dump()?.Deserialize();
+
+ if (ImGui.Button("Enable"))
+ _service.Enable();
+
+ ImGui.SameLine();
+ if (ImGui.Button("Disable"))
+ _service.Disable();
+
+ if (ImGui.Button("Shutdown Crash Handler"))
+ _service.CloseCrashHandler();
+ ImGui.SameLine();
+ if (ImGui.Button("Relaunch Crash Handler"))
+ _service.LaunchCrashHandler();
+ }
+
+ private void DrawDropSource()
+ {
+ _dragDrop.CreateImGuiSource("LogDragDrop", m => m.Files.Any(f => f.EndsWith("Penumbra.log")), m =>
+ {
+ ImGui.TextUnformatted("Dragging Penumbra.log for import.");
+ return true;
+ });
+ }
+
+ private void DrawDropTarget()
+ {
+ if (!_dragDrop.CreateImGuiTarget("LogDragDrop", out var files, out _))
+ return;
+
+ var file = files.FirstOrDefault(f => f.EndsWith("Penumbra.log"));
+ if (file == null)
+ return;
+
+ _lastLoadedFile = file;
+ try
+ {
+ var jObj = _service.Load(file);
+ _lastLoad = jObj?.Deserialize();
+ _lastLoadException = null;
+ }
+ catch (Exception ex)
+ {
+ _lastLoad = null;
+ _lastLoadException = ex;
+ }
+ }
+
+ private static void DrawObject(string name, CrashData? data, Exception? ex)
+ {
+ using var tree = ImRaii.TreeNode(name);
+ if (!tree)
+ return;
+
+ if (ex != null)
+ {
+ ImGuiUtil.TextWrapped(ex.ToString());
+ return;
+ }
+
+ if (data == null)
+ {
+ ImGui.TextUnformatted("Nothing loaded.");
+ return;
+ }
+
+ data.DrawMeta();
+ data.DrawFiles();
+ data.DrawCharacters();
+ data.DrawVfxInvocations();
+ }
+
+ private static void PrintValue(string label, in T data)
+ {
+ ImGuiUtil.DrawTableColumn(label);
+ ImGuiUtil.DrawTableColumn(data?.ToString() ?? "NULL");
+ }
+}
diff --git a/Penumbra/UI/Tabs/Debug/DebugTab.cs b/Penumbra/UI/Tabs/Debug/DebugTab.cs
index f4ddbe31..ab7ccf0c 100644
--- a/Penumbra/UI/Tabs/Debug/DebugTab.cs
+++ b/Penumbra/UI/Tabs/Debug/DebugTab.cs
@@ -53,7 +53,7 @@ public class Diagnostics(IServiceProvider provider)
foreach (var type in typeof(ActorManager).Assembly.GetTypes()
.Where(t => t is { IsAbstract: false, IsInterface: false } && t.IsAssignableTo(typeof(IAsyncDataContainer))))
{
- var container = (IAsyncDataContainer) provider.GetRequiredService(type);
+ var container = (IAsyncDataContainer)provider.GetRequiredService(type);
ImGuiUtil.DrawTableColumn(container.Name);
ImGuiUtil.DrawTableColumn(container.Time.ToString());
ImGuiUtil.DrawTableColumn(Functions.HumanReadableSize(container.Memory));
@@ -88,18 +88,22 @@ public class DebugTab : Window, ITab
private readonly TextureManager _textureManager;
private readonly ShaderReplacementFixer _shaderReplacementFixer;
private readonly RedrawService _redraws;
- private readonly DictEmote _emotes;
+ private readonly DictEmote _emotes;
private readonly Diagnostics _diagnostics;
private readonly IObjectTable _objects;
private readonly IClientState _clientState;
private readonly IpcTester _ipcTester;
+ private readonly CrashHandlerPanel _crashHandlerPanel;
- public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, IObjectTable objects, IClientState clientState,
- ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains, CharacterUtility characterUtility, ResidentResourceManager residentResources,
+ public DebugTab(PerformanceTracker performance, Configuration config, CollectionManager collectionManager, IObjectTable objects,
+ IClientState clientState,
+ ValidityChecker validityChecker, ModManager modManager, HttpApi httpApi, ActorManager actors, StainService stains,
+ CharacterUtility characterUtility, ResidentResourceManager residentResources,
ResourceManagerService resourceManager, PenumbraIpcProviders ipc, CollectionResolver collectionResolver,
DrawObjectState drawObjectState, PathState pathState, SubfileHelper subfileHelper, IdentifiedCollectionCache identifiedCollectionCache,
CutsceneService cutsceneService, ModImportManager modImporter, ImportPopup importPopup, FrameworkManager framework,
- TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes, Diagnostics diagnostics, IpcTester ipcTester)
+ TextureManager textureManager, ShaderReplacementFixer shaderReplacementFixer, RedrawService redraws, DictEmote emotes,
+ Diagnostics diagnostics, IpcTester ipcTester, CrashHandlerPanel crashHandlerPanel)
: base("Penumbra Debug Window", ImGuiWindowFlags.NoCollapse)
{
IsOpen = true;
@@ -134,7 +138,8 @@ public class DebugTab : Window, ITab
_redraws = redraws;
_emotes = emotes;
_diagnostics = diagnostics;
- _ipcTester = ipcTester;
+ _ipcTester = ipcTester;
+ _crashHandlerPanel = crashHandlerPanel;
_objects = objects;
_clientState = clientState;
}
@@ -158,6 +163,9 @@ public class DebugTab : Window, ITab
return;
DrawDebugTabGeneral();
+ ImGui.NewLine();
+ _crashHandlerPanel.Draw();
+ ImGui.NewLine();
_diagnostics.DrawDiagnostics();
DrawPerformanceTab();
ImGui.NewLine();
@@ -257,6 +265,7 @@ public class DebugTab : Window, ITab
}
}
+
var issues = _modManager.WithIndex().Count(p => p.Index != p.Value.Index);
using (var tree = TreeNode($"Mods ({issues} Issues)###Mods"))
{
@@ -394,7 +403,7 @@ public class DebugTab : Window, ITab
private void DrawPerformanceTab()
{
ImGui.NewLine();
- if (ImGui.CollapsingHeader("Performance"))
+ if (!ImGui.CollapsingHeader("Performance"))
return;
using (var start = TreeNode("Startup Performance", ImGuiTreeNodeFlags.DefaultOpen))
diff --git a/Penumbra/UI/Tabs/EffectiveTab.cs b/Penumbra/UI/Tabs/EffectiveTab.cs
index 8ef6c30e..37561000 100644
--- a/Penumbra/UI/Tabs/EffectiveTab.cs
+++ b/Penumbra/UI/Tabs/EffectiveTab.cs
@@ -8,31 +8,22 @@ using Penumbra.Collections;
using Penumbra.Collections.Cache;
using Penumbra.Collections.Manager;
using Penumbra.Meta.Manipulations;
-using Penumbra.Mods;
using Penumbra.Mods.Editor;
using Penumbra.String.Classes;
using Penumbra.UI.Classes;
namespace Penumbra.UI.Tabs;
-public class EffectiveTab : ITab
+public class EffectiveTab(CollectionManager collectionManager, CollectionSelectHeader collectionHeader)
+ : ITab
{
- private readonly CollectionManager _collectionManager;
- private readonly CollectionSelectHeader _collectionHeader;
-
- public EffectiveTab(CollectionManager collectionManager, CollectionSelectHeader collectionHeader)
- {
- _collectionManager = collectionManager;
- _collectionHeader = collectionHeader;
- }
-
public ReadOnlySpan Label
=> "Effective Changes"u8;
public void DrawContent()
{
SetupEffectiveSizes();
- _collectionHeader.Draw(true);
+ collectionHeader.Draw(true);
DrawFilters();
using var child = ImRaii.Child("##EffectiveChangesTab", -Vector2.One, false);
if (!child)
@@ -48,7 +39,7 @@ public class EffectiveTab : ITab
ImGui.TableSetupColumn(string.Empty, ImGuiTableColumnFlags.WidthFixed, _effectiveArrowLength);
ImGui.TableSetupColumn("##file", ImGuiTableColumnFlags.WidthFixed, _effectiveRightTextLength);
- DrawEffectiveRows(_collectionManager.Active.Current, skips, height,
+ DrawEffectiveRows(collectionManager.Active.Current, skips, height,
_effectiveFilePathFilter.Length > 0 || _effectiveGamePathFilter.Length > 0);
}
diff --git a/Penumbra/UI/Tabs/ModsTab.cs b/Penumbra/UI/Tabs/ModsTab.cs
index 9f070d35..d111c465 100644
--- a/Penumbra/UI/Tabs/ModsTab.cs
+++ b/Penumbra/UI/Tabs/ModsTab.cs
@@ -17,68 +17,53 @@ using Penumbra.Collections.Manager;
namespace Penumbra.UI.Tabs;
-public class ModsTab : ITab
+public class ModsTab(
+ ModManager modManager,
+ CollectionManager collectionManager,
+ ModFileSystemSelector selector,
+ ModPanel panel,
+ TutorialService tutorial,
+ RedrawService redrawService,
+ Configuration config,
+ IClientState clientState,
+ CollectionSelectHeader collectionHeader,
+ ITargetManager targets,
+ IObjectTable objectTable)
+ : ITab
{
- private readonly ModFileSystemSelector _selector;
- private readonly ModPanel _panel;
- private readonly TutorialService _tutorial;
- private readonly ModManager _modManager;
- private readonly ActiveCollections _activeCollections;
- private readonly RedrawService _redrawService;
- private readonly Configuration _config;
- private readonly IClientState _clientState;
- private readonly CollectionSelectHeader _collectionHeader;
- private readonly ITargetManager _targets;
- private readonly IObjectTable _objectTable;
-
- public ModsTab(ModManager modManager, CollectionManager collectionManager, ModFileSystemSelector selector, ModPanel panel,
- TutorialService tutorial, RedrawService redrawService, Configuration config, IClientState clientState,
- CollectionSelectHeader collectionHeader, ITargetManager targets, IObjectTable objectTable)
- {
- _modManager = modManager;
- _activeCollections = collectionManager.Active;
- _selector = selector;
- _panel = panel;
- _tutorial = tutorial;
- _redrawService = redrawService;
- _config = config;
- _clientState = clientState;
- _collectionHeader = collectionHeader;
- _targets = targets;
- _objectTable = objectTable;
- }
+ private readonly ActiveCollections _activeCollections = collectionManager.Active;
public bool IsVisible
- => _modManager.Valid;
+ => modManager.Valid;
public ReadOnlySpan Label
=> "Mods"u8;
public void DrawHeader()
- => _tutorial.OpenTutorial(BasicTutorialSteps.Mods);
+ => tutorial.OpenTutorial(BasicTutorialSteps.Mods);
public Mod SelectMod
{
- set => _selector.SelectByValue(value);
+ set => selector.SelectByValue(value);
}
public void DrawContent()
{
try
{
- _selector.Draw(GetModSelectorSize(_config));
+ selector.Draw(GetModSelectorSize(config));
ImGui.SameLine();
using var group = ImRaii.Group();
- _collectionHeader.Draw(false);
+ collectionHeader.Draw(false);
using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
- using (var child = ImRaii.Child("##ModsTabMod", new Vector2(-1, _config.HideRedrawBar ? 0 : -ImGui.GetFrameHeight()),
+ using (var child = ImRaii.Child("##ModsTabMod", new Vector2(-1, config.HideRedrawBar ? 0 : -ImGui.GetFrameHeight()),
true, ImGuiWindowFlags.HorizontalScrollbar))
{
style.Pop();
if (child)
- _panel.Draw();
+ panel.Draw();
style.Push(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
}
@@ -89,14 +74,14 @@ public class ModsTab : ITab
catch (Exception e)
{
Penumbra.Log.Error($"Exception thrown during ModPanel Render:\n{e}");
- Penumbra.Log.Error($"{_modManager.Count} Mods\n"
+ Penumbra.Log.Error($"{modManager.Count} Mods\n"
+ $"{_activeCollections.Current.AnonymizedName} Current Collection\n"
+ $"{_activeCollections.Current.Settings.Count} Settings\n"
- + $"{_selector.SortMode.Name} Sort Mode\n"
- + $"{_selector.SelectedLeaf?.Name ?? "NULL"} Selected Leaf\n"
- + $"{_selector.Selected?.Name ?? "NULL"} Selected Mod\n"
+ + $"{selector.SortMode.Name} Sort Mode\n"
+ + $"{selector.SelectedLeaf?.Name ?? "NULL"} Selected Leaf\n"
+ + $"{selector.Selected?.Name ?? "NULL"} Selected Mod\n"
+ $"{string.Join(", ", _activeCollections.Current.DirectlyInheritsFrom.Select(c => c.AnonymizedName))} Inheritances\n"
- + $"{_selector.SelectedSettingCollection.AnonymizedName} Collection\n");
+ + $"{selector.SelectedSettingCollection.AnonymizedName} Collection\n");
}
}
@@ -115,9 +100,9 @@ public class ModsTab : ITab
private void DrawRedrawLine()
{
- if (_config.HideRedrawBar)
+ if (config.HideRedrawBar)
{
- _tutorial.SkipTutorial(BasicTutorialSteps.Redrawing);
+ tutorial.SkipTutorial(BasicTutorialSteps.Redrawing);
return;
}
@@ -135,15 +120,15 @@ public class ModsTab : ITab
}
var hovered = ImGui.IsItemHovered();
- _tutorial.OpenTutorial(BasicTutorialSteps.Redrawing);
+ tutorial.OpenTutorial(BasicTutorialSteps.Redrawing);
if (hovered)
ImGui.SetTooltip($"The supported modifiers for '/penumbra redraw' are:\n{TutorialService.SupportedRedrawModifiers}");
using var id = ImRaii.PushId("Redraw");
- using var disabled = ImRaii.Disabled(_clientState.LocalPlayer == null);
+ using var disabled = ImRaii.Disabled(clientState.LocalPlayer == null);
ImGui.SameLine();
var buttonWidth = frameHeight with { X = ImGui.GetContentRegionAvail().X / 5 };
- var tt = _objectTable.GetObjectAddress(0) == nint.Zero
+ var tt = objectTable.GetObjectAddress(0) == nint.Zero
? "\nCan only be used when you are logged in and your character is available."
: string.Empty;
DrawButton(buttonWidth, "All", string.Empty, tt);
@@ -151,13 +136,13 @@ public class ModsTab : ITab
DrawButton(buttonWidth, "Self", "self", tt);
ImGui.SameLine();
- tt = _targets.Target == null && _targets.GPoseTarget == null
+ tt = targets.Target == null && targets.GPoseTarget == null
? "\nCan only be used when you have a target."
: string.Empty;
DrawButton(buttonWidth, "Target", "target", tt);
ImGui.SameLine();
- tt = _targets.FocusTarget == null
+ tt = targets.FocusTarget == null
? "\nCan only be used when you have a focus target."
: string.Empty;
DrawButton(buttonWidth, "Focus", "focus", tt);
@@ -176,9 +161,9 @@ public class ModsTab : ITab
if (ImGui.Button(label, size))
{
if (lower.Length > 0)
- _redrawService.RedrawObject(lower, RedrawType.Redraw);
+ redrawService.RedrawObject(lower, RedrawType.Redraw);
else
- _redrawService.RedrawAll(RedrawType.Redraw);
+ redrawService.RedrawAll(RedrawType.Redraw);
}
}
diff --git a/Penumbra/UI/Tabs/OnScreenTab.cs b/Penumbra/UI/Tabs/OnScreenTab.cs
index 8d323baf..09772d8e 100644
--- a/Penumbra/UI/Tabs/OnScreenTab.cs
+++ b/Penumbra/UI/Tabs/OnScreenTab.cs
@@ -7,7 +7,7 @@ namespace Penumbra.UI.Tabs;
public class OnScreenTab : ITab
{
private readonly Configuration _config;
- private ResourceTreeViewer _viewer;
+ private readonly ResourceTreeViewer _viewer;
public OnScreenTab(Configuration config, ResourceTreeFactory treeFactory, ChangedItemDrawer changedItemDrawer)
{
diff --git a/Penumbra/UI/Tabs/ResourceTab.cs b/Penumbra/UI/Tabs/ResourceTab.cs
index 6f3dec30..bbb0561b 100644
--- a/Penumbra/UI/Tabs/ResourceTab.cs
+++ b/Penumbra/UI/Tabs/ResourceTab.cs
@@ -12,24 +12,14 @@ using Penumbra.String.Classes;
namespace Penumbra.UI.Tabs;
-public class ResourceTab : ITab
+public class ResourceTab(Configuration config, ResourceManagerService resourceManager, ISigScanner sigScanner)
+ : ITab
{
- private readonly Configuration _config;
- private readonly ResourceManagerService _resourceManager;
- private readonly ISigScanner _sigScanner;
-
- public ResourceTab(Configuration config, ResourceManagerService resourceManager, ISigScanner sigScanner)
- {
- _config = config;
- _resourceManager = resourceManager;
- _sigScanner = sigScanner;
- }
-
public ReadOnlySpan Label
=> "Resource Manager"u8;
public bool IsVisible
- => _config.DebugMode;
+ => config.DebugMode;
/// Draw a tab to iterate over the main resource maps and see what resources are currently loaded.
public void DrawContent()
@@ -44,15 +34,15 @@ public class ResourceTab : ITab
unsafe
{
- _resourceManager.IterateGraphs(DrawCategoryContainer);
+ resourceManager.IterateGraphs(DrawCategoryContainer);
}
ImGui.NewLine();
unsafe
{
ImGui.TextUnformatted(
- $"Static Address: 0x{(ulong)_resourceManager.ResourceManagerAddress:X} (+0x{(ulong)_resourceManager.ResourceManagerAddress - (ulong)_sigScanner.Module.BaseAddress:X})");
- ImGui.TextUnformatted($"Actual Address: 0x{(ulong)_resourceManager.ResourceManager:X}");
+ $"Static Address: 0x{(ulong)resourceManager.ResourceManagerAddress:X} (+0x{(ulong)resourceManager.ResourceManagerAddress - (ulong)sigScanner.Module.BaseAddress:X})");
+ ImGui.TextUnformatted($"Actual Address: 0x{(ulong)resourceManager.ResourceManager:X}");
}
}
@@ -82,7 +72,7 @@ public class ResourceTab : ITab
ImGui.TableSetupColumn("Refs", ImGuiTableColumnFlags.WidthFixed, _refsColumnWidth);
ImGui.TableHeadersRow();
- _resourceManager.IterateResourceMap(map, (hash, r) =>
+ resourceManager.IterateResourceMap(map, (hash, r) =>
{
// Filter unwanted names.
if (_resourceManagerFilter.Length != 0
@@ -125,7 +115,7 @@ public class ResourceTab : ITab
if (tree)
{
SetTableWidths();
- _resourceManager.IterateExtMap(map, (ext, m) => DrawResourceMap(category, ext, m));
+ resourceManager.IterateExtMap(map, (ext, m) => DrawResourceMap(category, ext, m));
}
}
diff --git a/Penumbra/packages.lock.json b/Penumbra/packages.lock.json
index cb873592..d0e8fa1a 100644
--- a/Penumbra/packages.lock.json
+++ b/Penumbra/packages.lock.json
@@ -11,18 +11,6 @@
"Unosquare.Swan.Lite": "3.0.0"
}
},
- "Microsoft.CodeAnalysis.Common": {
- "type": "Direct",
- "requested": "[4.8.0, )",
- "resolved": "4.8.0",
- "contentHash": "/jR+e/9aT+BApoQJABlVCKnnggGQbvGh7BKq2/wI1LamxC+LbzhcLj4Vj7gXCofl1n4E521YfF9w0WcASGg/KA==",
- "dependencies": {
- "Microsoft.CodeAnalysis.Analyzers": "3.3.4",
- "System.Collections.Immutable": "7.0.0",
- "System.Reflection.Metadata": "7.0.0",
- "System.Runtime.CompilerServices.Unsafe": "6.0.0"
- }
- },
"Microsoft.Extensions.DependencyInjection": {
"type": "Direct",
"requested": "[7.0.0, )",
@@ -55,29 +43,15 @@
},
"SixLabors.ImageSharp": {
"type": "Direct",
- "requested": "[2.1.2, )",
- "resolved": "2.1.2",
- "contentHash": "In0pC521LqJXJXZgFVHegvSzES10KkKRN31McxqA1+fKtKsNe+EShWavBFQnKRlXCdeAmfx/wDjLILbvCaq+8Q==",
- "dependencies": {
- "System.Runtime.CompilerServices.Unsafe": "5.0.0",
- "System.Text.Encoding.CodePages": "5.0.0"
- }
- },
- "Microsoft.CodeAnalysis.Analyzers": {
- "type": "Transitive",
- "resolved": "3.3.4",
- "contentHash": "AxkxcPR+rheX0SmvpLVIGLhOUXAKG56a64kV9VQZ4y9gR9ZmPXnqZvHJnmwLSwzrEP6junUF11vuc+aqo5r68g=="
+ "requested": "[3.1.3, )",
+ "resolved": "3.1.3",
+ "contentHash": "wybtaqZQ1ZRZ4ZeU+9h+PaSeV14nyiGKIy7qRbDfSHzHq4ybqyOcjoifeaYbiKLO1u+PVxLBuy7MF/DMmwwbfg=="
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "h3j/QfmFN4S0w4C2A6X7arXij/M/OVw3uQHSOFxnND4DyAzO1F9eMX7Eti7lU/OkSthEE0WzRsfT/Dmx86jzCw=="
},
- "Microsoft.NETCore.Platforms": {
- "type": "Transitive",
- "resolved": "5.0.0",
- "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
- },
"SharpGLTF.Runtime": {
"type": "Transitive",
"resolved": "1.0.0-alpha0030",
@@ -86,32 +60,6 @@
"SharpGLTF.Core": "1.0.0-alpha0030"
}
},
- "System.Collections.Immutable": {
- "type": "Transitive",
- "resolved": "7.0.0",
- "contentHash": "dQPcs0U1IKnBdRDBkrCTi1FoajSTBzLcVTpjO4MBCMC7f4pDOIPzgBoX8JjG7X6uZRJ8EBxsi8+DR1JuwjnzOQ=="
- },
- "System.Reflection.Metadata": {
- "type": "Transitive",
- "resolved": "7.0.0",
- "contentHash": "MclTG61lsD9sYdpNz9xsKBzjsmsfCtcMZYXz/IUr2zlhaTaABonlr1ESeompTgM+Xk+IwtGYU7/voh3YWB/fWw==",
- "dependencies": {
- "System.Collections.Immutable": "7.0.0"
- }
- },
- "System.Runtime.CompilerServices.Unsafe": {
- "type": "Transitive",
- "resolved": "6.0.0",
- "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
- },
- "System.Text.Encoding.CodePages": {
- "type": "Transitive",
- "resolved": "5.0.0",
- "contentHash": "NyscU59xX6Uo91qvhOs2Ccho3AR2TnZPomo1Z0K6YpyztBPM/A5VbkzOO19sy3A3i1TtEnTxA7bCe3Us+r5MWg==",
- "dependencies": {
- "Microsoft.NETCore.Platforms": "5.0.0"
- }
- },
"System.ValueTuple": {
"type": "Transitive",
"resolved": "4.5.0",
@@ -134,11 +82,14 @@
"penumbra.api": {
"type": "Project"
},
+ "penumbra.crashhandler": {
+ "type": "Project"
+ },
"penumbra.gamedata": {
"type": "Project",
"dependencies": {
"OtterGui": "[1.0.0, )",
- "Penumbra.Api": "[1.0.13, )",
+ "Penumbra.Api": "[1.0.14, )",
"Penumbra.String": "[1.0.4, )"
}
},