Harmony field-or-property reflection tolerance¶
When reflecting against a game member whose declaration (field vs property) is unclear or might change between game versions, resolve it with a two-step lookup: AccessTools.Field(type, name) first, then AccessTools.PropertyGetter(type, name) if that returned null. Both return null on a miss instead of throwing, so the pair can be resolved once at plugin init and cached as a delegate.
Traverse.Create(target).Field(name) does NOT do this. It is a field-only lookup with no property fallback, and it fails to default(T) silently. See "Traverse.Field has no property fallback" below.
Problem¶
F0065 (Mods/PowerTransmitterPlus/RESEARCH.md:621-623):
PowerTransmitter.UsePowercheckscableNetwork == WirelessOutputNetwork, but it's unclear whetherWirelessOutputNetworkis a public field or property. UseTraverse.Create(t).Field("WirelessOutputNetwork").GetValue<CableNetwork>()with a property fallback.
A decompile showing the member on the right-hand side of an assignment can look like either a field or an auto-property; C# compiles them similarly but reflection must pick the right one. A direct typeof(X).GetField("WirelessOutputNetwork") returns null for a property, and vice versa.
The kind can also flip under a shipped mod, which turns this from a readability concern into an outage. Networks.StructureNetwork.StructureList was a public field up to 0.2.6403.27689 and is a get-only property at 0.2.6428.27798 (see ../GameClasses/RoboticArmRail.md). A mod compiled against the old shape emits ldfld, and Mono fails the JIT of the whole method containing that load, so the failure is not confined to the line that reads the member (Mods/SprayPaintPlus/playtests/NetworkWalkSurvivesGameUpdate.cs:1-57, which carries the player-reported text below verbatim):
[EXCEPTION] missingfieldexception: field not found:
system.collections.generic.list-1<networks.inetworkedstructure>
networks.structurenetwork.structurelist
Direct member access in C# source cannot survive that flip without a rebuild, because the compiler bakes the choice into the IL. Reflection can, which is the whole reason to spend it on a member that has moved once already.
Traverse.Field has no property fallback¶
Traverse.Field(string) performs one field-only lookup. There is no Property call, no PropertyInfo local, and no second lookup anywhere in the body:
// HarmonyLib.Traverse
public Traverse Field(string name)
{
if (name == null)
{
throw new ArgumentNullException("name");
}
Traverse traverse = Resolve();
if (traverse._type == null)
{
return new Traverse();
}
FieldInfo fieldInfo = Cache.GetFieldInfo(traverse._type, name);
if (fieldInfo == null)
{
return new Traverse();
}
if (!fieldInfo.IsStatic && traverse._root == null)
{
return new Traverse();
}
return new Traverse(traverse._root, fieldInfo, null);
}
AccessCache.GetFieldInfo is field-only at both levels (declared, then inherited); every fetcher calls GetField. The sibling GetPropertyInfo exists, but only Traverse.Property calls it:
// HarmonyLib.AccessCache
internal FieldInfo GetFieldInfo(Type type, string name, MemberType memberType = MemberType.Any, bool declaredOnly = false)
{
FieldInfo fieldInfo = Get(declaredFields, type, name, () => type.GetField(name, declaredOnlyBindingFlags[memberType]));
if (fieldInfo == null && !declaredOnly)
{
fieldInfo = Get(inheritedFields, type, name, () => AccessTools.FindIncludingBaseTypes(type, (Type t) => t.GetField(name, AccessTools.all)));
}
return fieldInfo;
}
The miss is silent. A property name yields fieldInfo == null, so Field returns the private parameterless new Traverse() with _type, _root, _info, _method and _params all null. GetValue() then matches none of its branches (_info is neither FieldInfo nor PropertyInfo, _method is null, and the _root == null && _type != null guard fails because _type is null too) and falls through to return _root, which is null. GetValue<T>() turns that into default(T):
public T GetValue<T>()
{
object value = GetValue();
if (value == null)
{
return default(T);
}
return (T)value;
}
So Traverse.Create(obj).Field("SomeProperty").GetValue<CableNetwork>() returns null and is indistinguishable from a field that genuinely held null. The same applies to Field<T>(name).Value, because Traverse<T>.Value just calls GetValue<T>().
Two further paths return the same silent default from Field: the resolved _type is null (the root was null), and the field was found but is an instance field while _root is null (Traverse.Create(typeof(X)).Field("instanceField")).
The four-member surface is the tell. Harmony exposes Field, Property, FieldExists and PropertyExists separately, and FieldExists / PropertyExists are pure _info is FieldInfo / _info is PropertyInfo tests on an already-resolved traverse. A method that silently accepted either kind would make both predicates pointless.
Reaching a property through Traverse needs Traverse.Property(name), which calls Cache.GetPropertyInfo.
AccessTools contract on a miss¶
AccessTools.all is Instance | Static | Public | NonPublic | GetField | SetField | GetProperty | SetProperty. FindIncludingBaseTypes walks BaseType upward and stops at the first non-null result; it does not cross into implemented interfaces.
| Member | On a miss | Search scope |
|---|---|---|
AccessTools.Field(Type, string) |
returns null, logs a warning | type and all super types |
AccessTools.DeclaredField(Type, string) |
returns null, logs a warning | declared on the type only |
AccessTools.Property(Type, string) |
returns null, logs a warning | type and all super types |
AccessTools.PropertyGetter(Type, string) |
returns null | type and all super types |
AccessTools.FieldRefAccess<T, F>(string) |
THROWS | type and all super types |
Field and Property are the same shape, a FindIncludingBaseTypes over GetField / GetProperty, both returning null rather than throwing:
public static FieldInfo Field(Type type, string name)
{
...
FieldInfo fieldInfo = FindIncludingBaseTypes(type, (Type t) => t.GetField(name, all));
if (fieldInfo == null)
{
Logger.Log(Logger.LogChannel.Warn, () => $"AccessTools.Field: Could not find field for type {type} and name {name}");
}
return fieldInfo;
}
A null type or a null name is also non-throwing for both: warn and return null.
PropertyGetter is a null-conditional over Property, with nonPublic: true so private getters are returned:
public static MethodInfo PropertyGetter(Type type, string name)
{
return Property(type, name)?.GetGetMethod(nonPublic: true);
}
It returns null both when the property is missing and when the property exists but has no getter (set-only), and the two cases are not distinguishable from the return value.
AccessTools.FieldRefAccess<T, F>(string) is the wrong tool for a tolerant read. It cannot reach a property at all, and it throws rather than returning null:
private static FieldInfo GetInstanceField(Type type, string fieldName)
{
FieldInfo obj = Field(type, fieldName) ?? throw new MissingFieldException(type.Name, fieldName);
if (obj.IsStatic)
{
throw new ArgumentException("Field must not be static");
}
return obj;
}
The MissingFieldException is then re-wrapped by the catch (Exception innerException) in FieldRefAccess, so the caller sees ArgumentException with the message FieldRefAccess<T, F> for <fieldName> caused an exception and the original as InnerException. A catch (MissingFieldException) at the call site will not fire. A static field, a value-type T, and a mismatched F (via ValidateFieldType<F>) all throw through the same wrapper. Only fieldName == null escapes unwrapped, as a bare ArgumentNullException, because that check sits ahead of the try.
An auto-property's backing field is named <Name>k__BackingField, so a plain property name can never accidentally match one through the field path.
Solution / recipe¶
Resolve once at plugin init, cache a delegate, and let the call sites stay ignorant of which kind won. Both lookups return null on a miss, so the fallback needs no exception handling:
// Resolved once, at plugin init.
private static Func<StructureNetwork, List<INetworkedStructure>> _readStructureList;
private static void ResolveStructureList()
{
var field = AccessTools.Field(typeof(StructureNetwork), "StructureList");
if (field != null)
{
_readStructureList = n => (List<INetworkedStructure>)field.GetValue(n);
return;
}
var getter = AccessTools.PropertyGetter(typeof(StructureNetwork), "StructureList");
if (getter != null)
{
_readStructureList = (Func<StructureNetwork, List<INetworkedStructure>>)Delegate.CreateDelegate(
typeof(Func<StructureNetwork, List<INetworkedStructure>>), getter);
return;
}
_readStructureList = null; // member is gone entirely; caller degrades instead of throwing
}
Three properties make this the shape to reach for:
- Neither lookup throws, so the "member vanished entirely" case arrives as a null delegate that the caller can test once, rather than as an exception on the hot path.
- Both search the type and all its super types, so a member that migrates onto a new base class (exactly what
StructureListdid when it moved toReferencableNetwork<TMember>.Members) is still found without naming the base. - Resolution happens once. Per-call
AccessToolslookups are cached inside Harmony forTraversebut not forAccessTools.Field/Property, which hit reflection every call.
The plain reflection equivalent, without Harmony, for reference:
var field = typeof(PowerTransmitter).GetField("WirelessOutputNetwork",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
CableNetwork value;
if (field != null)
{
value = (CableNetwork)field.GetValue(transmitter);
}
else
{
var prop = typeof(PowerTransmitter).GetProperty("WirelessOutputNetwork",
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
value = (CableNetwork)prop?.GetValue(transmitter);
}
Note this plain form searches only the named type, not its bases, which is the one thing AccessTools.Field / Property add.
The pattern is for members whose declaration-kind is uncertain or has already changed once. When the game version is pinned and the member is observably a field, a plain AccessTools.Field is simpler. Reflection is not free: it buys survival across a game update at the cost of a delegate call, so spend it on the members that have actually moved rather than on every read.
Verification history¶
- 2026-04-20: page created from the Research migration; single source (F0065).
- 2026-09-21: conflict on "does
Traverse.Field(name)fall back to a property". Previous claim (page line 48): "Traverse.Create(obj).Field("Name").GetValue<T>()(Harmony utility) hides the two-step check." New finding:Traverse.Field(string)does a single field-only lookup throughAccessCache.GetFieldInfo, returns an emptyTraverseon a miss, andGetValue<T>()on that returnsdefault(T)silently. Fresh validator verdict: the new finding is correct; the validator re-decompiledHarmonyLib.Traversefrom the shipped0Harmony.dll(HarmonyX 2.9.0.0) and confirmed the body contains noPropertycall, noPropertyInfolocal and no second lookup. Result: the false sentence is removed; a new "Traverse.Field has no property fallback" section carries the decompiled bodies and the silent-default trace; the intro and "Solution / recipe" now recommend theAccessTools.FieldthenAccessTools.PropertyGettertwo-step resolved once into a cached delegate. Added an "AccessTools contract on a miss" section recording thatField,PropertyandPropertyGetterreturn null and search all super types whileFieldRefAccess<T, F>throws a wrappedMissingFieldExceptionand cannot reach a property. Added the measuredNetworks.StructureNetwork.StructureListfield-to-property flip to "Problem" as the concrete case this pattern exists for.
Open questions¶
- Whether any other game member this repo reflects against has flipped kind across a game update. Only
StructureListhas been measured. A sweep comparing member kinds between two game versions would answer it, but no pre-0.2.6428.27798 assembly is kept on this machine (the rig installs are hard links to the live one), so the comparison needs an assembly that is not currently available.