Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Source/Client/Debug/DebugActions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using HarmonyLib;
using LudeonTK;
using Multiplayer.Client.Desyncs;
using Multiplayer.Common;
using Multiplayer.Client.Util;
using Multiplayer.Client.Windows;
using RimWorld;
Expand Down Expand Up @@ -456,15 +457,17 @@ static string StaticFieldsToString(Assembly asm, Predicate<Type> typeValidator)

object FieldValue(FieldInfo field)
{
var value = field.GetValue(null);
if (!StaticFieldDump.TryReadStaticValue(field, out var value, out var failure))
return $"[unreadable: {failure}]";

if (value is ICollection col)
return col.Count;
if (field.Name.ToLowerInvariant().Contains("path") && value is string path && (path.Contains("/") || path.Contains("\\")))
return "[x]";
return value;
}

foreach (var type in asm.GetTypes())
foreach (var type in StaticFieldDump.TypesOf(asm))
if (!type.IsGenericTypeDefinition && type.Namespace != null && typeValidator(type) && !type.HasAttribute<DefOf>() && !type.HasAttribute<CompilerGeneratedAttribute>())
foreach (var field in type.GetFields(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly))
if (!field.IsLiteral && !field.IsInitOnly && !field.HasAttribute<CompilerGeneratedAttribute>())
Expand Down
70 changes: 70 additions & 0 deletions Source/Common/StaticFieldDump.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;

namespace Multiplayer.Common
{
/// <summary>
/// The reflection behind the "Print static fields" debug dump, which lists mutable static state across
/// loaded assemblies. Unsynchronized static state is a common source of desyncs, so the dump is one of
/// the first things reached for when a session diverges.
///
/// Kept here rather than beside the debug action so it can be tested. The test project cannot
/// reference the client, which needs the game's own assemblies to load.
/// </summary>
public static class StaticFieldDump
{
/// <summary>
/// Reads a static field's current value.
///
/// Contract: never propagate. Reading a static field runs its declaring type's initializer, which
/// is arbitrary code and can fail for reasons that have nothing to do with the dump. A caller
/// wants the other few thousand fields even when one is unreadable.
///
/// MonoMod is the case that forced this: it ships every platform's interop types in one assembly,
/// so the ones belonging to another operating system throw DllNotFoundException the moment they
/// are touched. That is true on every platform -- only the type names change.
/// </summary>
/// <param name="field">The static field to read.</param>
/// <param name="value">The value read, or null when it could not be read.</param>
/// <param name="failure">A short description of why it could not be read, or null on success.</param>
/// <returns>Whether the value was read.</returns>
public static bool TryReadStaticValue(FieldInfo field, out object value, out string failure)
{
try
{
value = field.GetValue(null);
failure = null;
return true;
}
catch (Exception e)
{
// The base exception, because the interesting part is what the initializer actually hit.
// Reporting TypeInitializationException would name the mechanism and hide the cause.
value = null;
failure = e.GetBaseException().GetType().Name;
return false;
}
}

/// <summary>
/// The types in <paramref name="assembly"/>.
///
/// Contract: report whatever loaded. An assembly referencing something absent cannot enumerate
/// all of its types, but it still resolves most of them, and those are worth dumping.
/// </summary>
public static IEnumerable<Type> TypesOf(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
// Types is padded with nulls for the entries that failed to load.
return e.Types.Where(t => t != null);
}
}
}
}
60 changes: 60 additions & 0 deletions Source/Tests/StaticFieldDumpTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Reflection;
using Multiplayer.Common;

namespace Tests;

public class StaticFieldDumpTest
{
/// <summary>
/// Stands in for the platform-specific interop types MonoMod ships in a single assembly. Reading any
/// static on the ones belonging to a different operating system runs an initializer that P/Invokes a
/// library the host does not have. The shape is identical on every platform; only the type names
/// differ, so this reproduces it without depending on which OS the tests run under.
///
/// The explicit static constructor is deliberate: it suppresses beforefieldinit, so initialization
/// happens exactly when the field is read rather than at some earlier point of the runtime's choosing.
/// </summary>
private static class FailsToInitialize
{
public static int Value;

static FailsToInitialize() => throw new DllNotFoundException("libc");
}

private static class Ordinary
{
public static int Value;

static Ordinary() => Value = 42;
}

private static FieldInfo StaticFieldOf(Type type, string name)
=> type.GetField(name, BindingFlags.Public | BindingFlags.Static);

[Test]
public void TryReadStaticValue_ReadsAnOrdinaryField()
{
var read = StaticFieldDump.TryReadStaticValue(
StaticFieldOf(typeof(Ordinary), nameof(Ordinary.Value)),
out var value,
out var failure);

Assert.That(read, Is.True);
Assert.That(value, Is.EqualTo(42));
Assert.That(failure, Is.Null);
}

[Test]
public void TryReadStaticValue_ReportsAFailingInitializerInsteadOfPropagating()
{
var read = StaticFieldDump.TryReadStaticValue(
StaticFieldOf(typeof(FailsToInitialize), nameof(FailsToInitialize.Value)),
out _,
out var failure);

Assert.That(read, Is.False, "an unreadable field must be reported, not thrown");
Assert.That(failure, Does.Contain(nameof(DllNotFoundException)),
"the report should name the underlying cause, not the TypeInitializationException wrapping it");
}
}