mirror of
https://github.com/HeyPuter/gex.git
synced 2026-11-13 20:12:43 -10:00
unity-dotnet provider: report liveness at the end of each calculation
Unity drains its mark queues before it calls mono_unity_liveness_finalize, so
objects the walk reported only at finalize were marked with none of their
native dependencies: a component kept without its GameObject (the Instantiate
crash) and a dynamic Font kept without its atlas texture (Hollow Knight's
menu labels drew as black boxes). Report at the end of every from_root and
from_statics, as Unity's Mono does. The stand-in GameObject workaround
(6b119e15) is removed and the walk is on by default (GEXMONO_LIVENESS=0 turns
it off; GEXMONO_LV_LATE=1 restores the late flush for comparison).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_461fe3ff06374ecba693831148697ba6
This commit is contained in:
1 parent
3f776223ae
commit
e61b5a4bab
2 files changed
+72
-81
No files matched your search
+44
-1
@@ -1049,7 +1049,7 @@ Not yet:
|
||||
MONO_GC_DEBUG=verify-before-collections / check-at-minor style heap
|
||||
checks, and the class.c:2272 caller.
|
||||
13. **Gate head 55c3197f (2026-10-06):** liveness walk opt-in
|
||||
(`GEXMONO_LIVENESS=1`; off = the old stubs), netlink socket, F's notes,
|
||||
(`GEXMONO_LIVENESS=1` then; on by default since item 18), netlink socket, F's notes,
|
||||
HOME_SEED/OVERLAY/xfocus/probe tooling. Legs on it with `scratch/unity/dnc`
|
||||
rebuilt from it: embed PASS, prov64 PASS, fjit-procs ok/ok, Muck batch
|
||||
--fjit reaches "Initializing packets", HK batch reaches "Unloading 153" and
|
||||
@@ -1156,4 +1156,47 @@ Not yet:
|
||||
title, particles, menu arrows); the menu item labels draw as black
|
||||
boxes (text: TextMeshPro font warnings earlier in the log; next to look
|
||||
at), cameras 5, ~50k renders.
|
||||
18. **Root cause of 12-17 and of the black labels: the walk reported too
|
||||
late (2026-10-06, runs hk102-hk107).** Unity's GC driver (UnityPlayer
|
||||
0x130d26b on) runs, per GC: the mark jobs, each draining its state's
|
||||
queue (0x12f2e60; the queue is what the mark function 0x12f1ef0 pushes
|
||||
an object's entry onto, unless the entry has flag 0x8), with
|
||||
`from_root` called from the MonoBehaviour case and `from_statics` on
|
||||
the main state; then, after every queue is drained,
|
||||
`mono_unity_liveness_finalize` on each state (0x12f30a0); then
|
||||
`free_struct`. The provider reported objects to Unity's callback only
|
||||
when its buffer filled or at finalize, so most objects found by the walk
|
||||
were marked after the last drain: marked, kept, but none of their native
|
||||
dependencies followed. Unity's Mono reports at the end of each
|
||||
`from_root`/`from_statics` call (unity-liveness.c), which the provider
|
||||
now does too (`lv_flush_now`; `GEXMONO_LV_LATE=1` restores the old
|
||||
behaviour for comparison).
|
||||
This explains all of it: the spinner component kept without its
|
||||
GameObject (items 14-17: its entry was never dequeued, so the
|
||||
dependency step and its flag-0x2 test never ran for it; flag 0x2 is set
|
||||
on nearly every entry, it was a red herring), and the black labels: a
|
||||
dynamic Font kept by script data, its atlas texture (the Font's PPtrs at
|
||||
native +0x30 material, +0x34 texture) unloaded and reloaded from disk as
|
||||
the serialized 0x0 texture while the Font's glyph cache stayed, so glyph
|
||||
updates went to a GL texture with no storage (the GL capture: texture
|
||||
459 generated then only `TexSubImage2D`'d, `GetNativeTexturePtr` 458 on a
|
||||
0x0 "Font Texture"; GexProbe `GEXPROBE_FONTS=1` shows each font's atlas).
|
||||
Not a liveness victim of TextMeshPro (its font assets were alive, hk100)
|
||||
nor a GL-path problem.
|
||||
With the fix and **no workaround** (the stand-in GameObject reporting of
|
||||
item 17 is removed) Hollow Knight passes the spinner Instantiate, reaches
|
||||
Menu_Title with graphics, and the menu labels render
|
||||
(`scratch/unity/screens/hk-menu-labels.png`, hk107). The walk is now on by
|
||||
default (`GEXMONO_LIVENESS=0` turns it off).
|
||||
How found: a diagnostic that read Unity's GC table from the callback's
|
||||
userdata (Unity 6000.0 linux-x64: `[data+8]` = table, entries of 24
|
||||
bytes {object, RTTI, flags} at table+0, buckets of 12 bytes {hash,
|
||||
instance ID, entry index} at table+0x48, mask x4 at table+0x50; RTTI
|
||||
+16 = class name) showed the marked Font with its texture and material
|
||||
unmarked; reading the driver showed when finalize runs.
|
||||
**BinaryFormatter:** `build.sh`'s `formatters` step (63c7f5da) restores
|
||||
the out-of-band System.Runtime.Serialization.Formatters NuGet package
|
||||
(10.0.*, its net8.0+ assembly) into `OUT/lib-extra`, which
|
||||
`muck-dotnet.sh` copies over the framework's stub; HK's
|
||||
`JsonSharedData.Save` no longer throws (hk102).
|
||||
|
||||
@@ -429,7 +429,6 @@ U(mono_unity_free_captured_memory_snapshot) { ENTER; RETV; }
|
||||
* objects meanwhile. */
|
||||
typedef struct {
|
||||
MonoClass *filter;
|
||||
GPtrArray *fakes; /* stand-in wrappers for GameObjects (lv_keep_gameobject) */
|
||||
guint32 max;
|
||||
uint64_t cb, data;
|
||||
void **arr;
|
||||
@@ -449,21 +448,21 @@ lv_new (MonoClass *filter, guint32 max, uint64_t cb, uint64_t data)
|
||||
s->arr = g_new0 (void *, s->max);
|
||||
s->seen = g_hash_table_new (NULL, NULL);
|
||||
s->stack = g_ptr_array_new ();
|
||||
s->fakes = g_ptr_array_new ();
|
||||
return s;
|
||||
}
|
||||
|
||||
static int lv_env (const char *name);
|
||||
/* The walk is opt-in (GEXMONO_LIVENESS=1) until the Instantiate crash it
|
||||
* exposes in Hollow Knight is understood (docs/unity-dotnet.md §12); off, the
|
||||
* provider reports nothing, as before. GEXMONO_LV_OFF=1 walks but reports
|
||||
* nothing. */
|
||||
/* The walk is on by default; GEXMONO_LIVENESS=0 turns it off (the provider then reports
|
||||
* nothing: Unity unloads every asset only script data holds). GEXMONO_LV_OFF=1 walks but
|
||||
* reports nothing. */
|
||||
static int
|
||||
lv_on (void)
|
||||
{
|
||||
static int on = -1;
|
||||
if (on < 0)
|
||||
on = lv_env ("GEXMONO_LIVENESS");
|
||||
if (on < 0) {
|
||||
const char *v = getenv ("GEXMONO_LIVENESS");
|
||||
on = !v || !*v || strcmp (v, "0");
|
||||
}
|
||||
return on;
|
||||
}
|
||||
/* GEXMONO_LV_TRACE=1: each reported object and each root, as class and Unity instance ID
|
||||
@@ -506,9 +505,6 @@ lv_free (Liveness *s)
|
||||
return;
|
||||
g_hash_table_destroy (s->seen);
|
||||
g_ptr_array_free (s->stack, TRUE);
|
||||
for (guint i = 0; i < s->fakes->len; i++)
|
||||
g_free (g_ptr_array_index (s->fakes, i));
|
||||
g_ptr_array_free (s->fakes, TRUE);
|
||||
g_free (s->arr);
|
||||
g_free (s);
|
||||
}
|
||||
@@ -520,7 +516,6 @@ lv_free (Liveness *s)
|
||||
* next pass. */
|
||||
static int lv_env (const char *name);
|
||||
static GHashTable *lv_pass_seen;
|
||||
static GHashTable *lv_pass_go; /* GameObjects already reported this pass (lv_keep_gameobject) */
|
||||
static pthread_mutex_t lv_pass_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static void
|
||||
@@ -529,8 +524,6 @@ lv_pass_reset (void)
|
||||
pthread_mutex_lock (&lv_pass_lock);
|
||||
if (lv_pass_seen)
|
||||
g_hash_table_remove_all (lv_pass_seen);
|
||||
if (lv_pass_go)
|
||||
g_hash_table_remove_all (lv_pass_go);
|
||||
pthread_mutex_unlock (&lv_pass_lock);
|
||||
}
|
||||
|
||||
@@ -613,16 +606,6 @@ lv_fields (Liveness *s, MonoClass *k, char *base, gboolean boxed)
|
||||
}
|
||||
}
|
||||
|
||||
/* Workaround, pending the real rule (docs/unity-dotnet.md §12 item 16-17): Unity's GC marks a
|
||||
* component's GameObject from the component only when the component's GC entry has a flag (0x2)
|
||||
* that a component kept alive through script data does not get here, so the GameObject was
|
||||
* unloaded under a live component (Hollow Knight's loading-spinner prefab). For each reported
|
||||
* UnityEngine.Component the walk also reports its GameObject, through a stand-in for a managed
|
||||
* wrapper: Unity's callback reads only the wrapper's m_CachedPtr (+0x10) and the native object's
|
||||
* instance ID (+8); a Component's native GameObject pointer is at +0x20.
|
||||
* GEXMONO_LV_NOGO=1 turns it off. */
|
||||
static MonoClass *lv_component_class;
|
||||
|
||||
static void
|
||||
lv_report (Liveness *s, MonoObject *o)
|
||||
{
|
||||
@@ -631,58 +614,12 @@ lv_report (Liveness *s, MonoObject *o)
|
||||
lv_flush (s);
|
||||
}
|
||||
|
||||
static void
|
||||
lv_keep_gameobject (Liveness *s, MonoObject *o, MonoClass *k)
|
||||
{
|
||||
static int off = -1;
|
||||
if (off < 0)
|
||||
off = lv_env ("GEXMONO_LV_NOGO");
|
||||
if (off || !lv_component_class || !mono_class_is_subclass_of_internal (k, lv_component_class, FALSE))
|
||||
return;
|
||||
uint8_t *native = *(uint8_t **)((char *)o + MONO_ABI_SIZEOF (MonoObject));
|
||||
if ((uintptr_t)native < 0x10000 || (uintptr_t)native >= ((uintptr_t)1 << 34))
|
||||
return;
|
||||
uint8_t *go = *(uint8_t **)(native + 0x20);
|
||||
if ((uintptr_t)go < 0x10000 || (uintptr_t)go >= ((uintptr_t)1 << 34))
|
||||
return;
|
||||
pthread_mutex_lock (&lv_pass_lock);
|
||||
if (!lv_pass_go)
|
||||
lv_pass_go = g_hash_table_new (NULL, NULL);
|
||||
gboolean seen = g_hash_table_contains (lv_pass_go, go);
|
||||
if (!seen)
|
||||
g_hash_table_add (lv_pass_go, go);
|
||||
pthread_mutex_unlock (&lv_pass_lock);
|
||||
if (seen)
|
||||
return;
|
||||
uintptr_t *fake = g_new0 (uintptr_t, 3);
|
||||
fake [2] = (uintptr_t)go; /* where m_CachedPtr sits after the 16-byte header */
|
||||
g_ptr_array_add (s->fakes, fake);
|
||||
lv_report (s, (MonoObject *)fake);
|
||||
/* And the GameObject's components (its component array: {type, component} pairs at
|
||||
* [go+0x20], count at [go+0x30]), which Unity does not pull in from the GameObject either. */
|
||||
uintptr_t *comps = *(uintptr_t **)(go + 0x20);
|
||||
uintptr_t count = *(uintptr_t *)(go + 0x30);
|
||||
if ((uintptr_t)comps < 0x10000 || (uintptr_t)comps >= ((uintptr_t)1 << 34) || count > 256)
|
||||
return;
|
||||
for (uintptr_t i = 0; i < count; i++) {
|
||||
uintptr_t comp = comps [2 * i + 1];
|
||||
if (comp < 0x10000 || comp >= ((uintptr_t)1 << 34))
|
||||
continue;
|
||||
uintptr_t *f = g_new0 (uintptr_t, 3);
|
||||
f [2] = comp;
|
||||
g_ptr_array_add (s->fakes, f);
|
||||
lv_report (s, (MonoObject *)f);
|
||||
}
|
||||
}
|
||||
|
||||
static void
|
||||
lv_object_walk (Liveness *s, MonoObject *o, gboolean report)
|
||||
{
|
||||
MonoClass *k = mono_object_class (o);
|
||||
if (report && s->filter && mono_class_is_subclass_of_internal (k, s->filter, FALSE)) {
|
||||
if (report && s->filter && mono_class_is_subclass_of_internal (k, s->filter, FALSE))
|
||||
lv_report (s, o);
|
||||
lv_keep_gameobject (s, o, k);
|
||||
}
|
||||
if (m_class_get_rank (k)) {
|
||||
MonoArray *a = (MonoArray *)o;
|
||||
MonoClass *ek = m_class_get_element_class (k);
|
||||
@@ -741,21 +678,30 @@ void sgen_gc_lock (void);
|
||||
void sgen_gc_unlock (void);
|
||||
static int lv_world_stopped;
|
||||
|
||||
/* allocate_struct (filter, max_count, callback, userdata, reallocate): Unity 2021+. */
|
||||
/* Report what a from_root / from_statics call found before returning, as Unity's Mono does
|
||||
* (unity-liveness.c filters and calls back at the end of each calculation): Unity marks the
|
||||
* reported objects into its queue and drains it (their native dependencies: a Font's atlas
|
||||
* texture and material, a component's GameObject...) before it calls liveness_finalize, so
|
||||
* objects first reported from finalize were marked with none of their dependencies, which
|
||||
* Unity then unloaded under them (Hollow Knight's menu labels drew from a font whose atlas
|
||||
* texture had been unloaded and reloaded empty). GEXMONO_LV_LATE=1 restores the old
|
||||
* flush-at-finalize behaviour, for comparison. */
|
||||
static void
|
||||
lv_resolve_component (MonoClass *filter)
|
||||
lv_flush_now (Liveness *s)
|
||||
{
|
||||
if (lv_component_class || !filter)
|
||||
return;
|
||||
ERROR_DECL (err);
|
||||
lv_component_class = mono_class_from_name_checked (m_class_get_image (filter), "UnityEngine", "Component", err);
|
||||
mono_error_cleanup (err);
|
||||
static int late = -1;
|
||||
if (late < 0)
|
||||
late = lv_env ("GEXMONO_LV_LATE");
|
||||
if (!late)
|
||||
lv_flush (s);
|
||||
}
|
||||
U(mono_unity_liveness_allocate_struct) { ENTER; lv_resolve_component ((MonoClass *)P (0)); RET (lv_new ((MonoClass *)P (0), (guint32)A (1), A (2), A (3))); }
|
||||
|
||||
/* allocate_struct (filter, max_count, callback, userdata, reallocate): Unity 2021+. */
|
||||
U(mono_unity_liveness_allocate_struct) { ENTER; RET (lv_new ((MonoClass *)P (0), (guint32)A (1), A (2), A (3))); }
|
||||
U(mono_unity_liveness_free_struct) { ENTER; lv_free ((Liveness *)P (0)); RETV; }
|
||||
U(mono_unity_liveness_finalize) { ENTER; if (P (0)) lv_flush ((Liveness *)P (0)); RETV; }
|
||||
/* calculation_begin (filter, max_count, callback, userdata, world_start, world_stop): the older form. */
|
||||
U(mono_unity_liveness_calculation_begin) { ENTER; lv_pass_reset (); lv_resolve_component ((MonoClass *)P (0)); RET (lv_new ((MonoClass *)P (0), (guint32)A (1), A (2), A (3))); }
|
||||
U(mono_unity_liveness_calculation_begin) { ENTER; lv_pass_reset (); RET (lv_new ((MonoClass *)P (0), (guint32)A (1), A (2), A (3))); }
|
||||
U(mono_unity_liveness_calculation_end) { ENTER; if (P (0)) lv_flush ((Liveness *)P (0)); lv_free ((Liveness *)P (0)); RETV; }
|
||||
U(mono_unity_liveness_calculation_from_root)
|
||||
{
|
||||
@@ -772,6 +718,7 @@ U(mono_unity_liveness_calculation_from_root)
|
||||
m_class_get_name (mono_object_class (P (0))), lv_iid (P (0)));
|
||||
lv_object_walk (s, (MonoObject *)P (0), FALSE);
|
||||
lv_drain (s);
|
||||
lv_flush_now (s);
|
||||
}
|
||||
RETV;
|
||||
}
|
||||
@@ -796,6 +743,7 @@ U(mono_unity_liveness_calculation_from_statics)
|
||||
}
|
||||
g_ptr_array_free (vts, TRUE);
|
||||
lv_drain (s);
|
||||
lv_flush_now (s);
|
||||
}
|
||||
RETV;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user