chore(engine-ambient): harden shim + LocalLog statics for fixture parallelism

Follow-up to the multi-instancing migration. Wraps the process-shared engine
statics that aren't ambient-fronted but race between concurrent battles:

- UnityEngine.Resources._loaded: Dictionary -> ConcurrentDictionary.GetOrAdd
  (the shared prefab cache keyed by path; concurrent first-misses produced
  duplicate GameObjects + Dictionary corruption)
- UnityEngine.GameObject._components: Dictionary -> ConcurrentDictionary with
  Interlocked.CompareExchange init (Resources.Load returns SHARED prefab
  GameObjects, so two engines' Setup() can race on the same _components map
  — surfaced as "Operations that change non-concurrent collections" crashes
  during BattleManagerBase ctor's GetComponent<T>() chain)
- Wizard.LocalLog: single static lock around all mutating entry points
  (StringBuilder _lastTraceLogStringBuilder + ~12 mutable string/bool/int
  scratch fields; serializing the trace-log surface is cheap since logging
  is not the hot path)

Flips SVSim.BattleEngine.Tests assembly Parallelizable scope from Self to
Fixtures and restructures MultiInstanceEngineTests.StressN_BaselineMatches so
Setup runs INSIDE Task.Run (was previously serialized as a workaround for the
LocalLog races). The fixture is also lifted to ParallelScope.All so the
two-engines and stress tests can run alongside each other.

Suite fully green under fixture parallelism (59/0/2 across 3 consecutive runs);
SVSim.UnitTests still 1054/0/0 — true multi-instance correctness is now proved
end-to-end in tests rather than gated behind a serial workaround.

Manifest sha refresh + new patch artifact for the LocalLog edit (decomp-origin);
the two shim files are authored, so no metadata update is needed for them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-08 08:02:49 -04:00
parent 45344e6d83
commit fbac66fd0b
7 changed files with 360 additions and 174 deletions

View File

@@ -54,13 +54,13 @@ namespace UnityEngine
// dictionary that the resolution-path ctor then Instantiate()s + GetComponent()s
// (e.g. Prefab/Game/UnityEventAgent). Return a cached no-op GameObject per path so that
// chain yields a non-null object. Typed asset loads go through the generic Load<T> (null).
private static readonly System.Collections.Generic.Dictionary<string, GameObject> _loaded
= new System.Collections.Generic.Dictionary<string, GameObject>();
// ConcurrentDictionary + GetOrAdd so concurrent battle setups don't race on first-miss.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, GameObject> _loaded
= new System.Collections.Concurrent.ConcurrentDictionary<string, GameObject>();
public static Object Load(string path)
{
if (string.IsNullOrEmpty(path)) return null;
if (!_loaded.TryGetValue(path, out var go)) { go = new GameObject(path); _loaded[path] = go; }
return go;
return _loaded.GetOrAdd(path, static p => new GameObject(p));
}
public static Object Load(string path, Type t) => Load(path);
public static T[] LoadAll<T>(string path) where T : Object => new T[0];

View File

@@ -213,19 +213,27 @@ namespace UnityEngine
// off prefab GameObjects and use them unguarded (F1). Lazily create + cache a no-op instance
// per concrete Component-derived type so those touches resolve harmlessly instead of NRE.
// Non-Component T or abstract/uninstantiable T still returns default (null).
private System.Collections.Generic.Dictionary<Type, object> _components;
// ConcurrentDictionary because Resources.Load returns SHARED prefab GameObjects across
// concurrent battle setups, so two engines' Setup() may race on the same _components map.
private System.Collections.Concurrent.ConcurrentDictionary<Type, object> _components;
private object GetOrAddComponent(Type t)
{
if (t == null || t.IsAbstract || !typeof(Component).IsAssignableFrom(t)) return null;
_components ??= new System.Collections.Generic.Dictionary<Type, object>();
if (_components.TryGetValue(t, out var c)) return c;
object inst;
try { inst = Activator.CreateInstance(t); }
catch { return null; }
_components[t] = inst;
if (inst is Component comp) comp._go = this;
WireComponentFields(inst);
return inst;
var map = _components;
if (map == null)
{
var fresh = new System.Collections.Concurrent.ConcurrentDictionary<Type, object>();
map = System.Threading.Interlocked.CompareExchange(ref _components, fresh, null) ?? fresh;
}
return map.GetOrAdd(t, ty =>
{
object inst;
try { inst = Activator.CreateInstance(ty); }
catch { return null; }
if (inst is Component comp) comp._go = this;
WireComponentFields(inst);
return inst;
});
}
// The createNullView:false card-creation path reads many view-leaf reference fields off a
// CardTemplate component (UILabel/MeshRenderer/Transform/GameObject) UNGUARDED, plus the