Skip to content

lcmem Mono Internals

NNKtv28 edited this page Sep 28, 2026 · 1 revision

Predicted field offsets

.NET metadata does not store instance field offsets. Mono assigns them when the type loads. lcmem rebuilds Mono's own layout algorithm. Two rules matter, and both were confirmed against this game's live memory.

Inheritance. A MonoBehaviour subclass starts at +0x20: 0x10 of object header, UnityEngine.Object.m_CachedPtr, then MonoBehaviour's own IntPtr.

Reference fields come first. For a class with GC references, Mono uses a "GC aware" layout. It places reference-typed fields before all others, so the collector can scan one compact run of pointers. Declaration order is kept within each group.

Example: in CameraFXManager, CameraFOV is declared fourth but lives at +0x54, after all six object references. Declaration order would put it at +0x2C, where it reads as 6.85e-43: the high half of a pointer. That value is the typical sign of a wrong layout.

$ python -m lcmem object 0x01E99F1C2420
0x01E99F1C2420 as CameraFX.CameraFXManager
   +0x020 UnityEngine.Light      _flashLight        = 0x1E99F455920 (UnityEngine.Light)
   +0x028 UnityEngine.Camera     _camera            = 0x1E99F52D720 (UnityEngine.Camera)
   +0x050 u8                     CameraFOVControl   = 0
   +0x054 f32                    CameraFOV          = 65.0
   +0x058 f32                    CameraFOVSpeed     = 5.0
   +0x05C f32                    _defaultFov        = 65.0

Every reference resolves to its declared type and every number is plausible. Hold a layout to that standard before you write to it.

Offsets are still a reconstruction. Confirm with object <addr> first. Prediction stops at the first field whose size is not statically known; those fields show no offset instead of a wrong one.

Reading structured data

mono.read_list(addr)        # List<T> of references, honouring _size
mono.read_array(addr)       # T[] of references
mono.read_datetime(addr)    # System.DateTime -> Python datetime
mono.read_guid(addr)        # System.Guid
mono.read_string_field(a)   # follow a string field, tolerating null

Types across assemblies

A class in Assembly-CSharp often inherits from one in VWW.CoreLibs.*. AssemblySet loads several DLLs into one registry so those chains resolve:

from lcmem.monometa import AssemblySet
asm = AssemblySet.from_folder(managed)      # 196 assemblies, 32k types
asm.get("ChatChannelInfo")

Speed

  • heap_regions(): the process maps about 26 GB, but managed objects use about 1.1 GB. A two-stage sample finds that slice in about 5 s and cuts an instance sweep from about 250 s to about 18 s. It is only a hint: if it finds nothing, the caller sweeps everything.
  • Session cache: mono.save_cache(path) / load_cache(path) keep the discovered layout and class addresses, keyed by process ID. Boehm GC never moves objects, so they stay valid for the life of the process. Every cached value is checked again before use, because process IDs get reused.

Module layout

lcmem/
  win32.py      ctypes bindings and privilege handling
  process.py    attach, modules, regions, typed read/write, Mono string reads
  scanner.py    first/refine scanning, numpy-backed, dense + sparse modes
  monometa.py   ECMA-335 parser for Assembly-CSharp.dll
  mono.py       runtime introspection: object -> vtable -> class -> name
  patches.py    declarative patches, AOB search, backup/revert
  cli.py        command line and interactive shell

Clone this wiki locally