feat(wb): ConsoleErrorLogger + cause report — H1 swallowed-exception confirmed

Phase 2 diagnostic chain identified the EXACT cause of 26/123 Holtburg
cells silently failing in WB's PrepareEnvCellMeshData:
ArgumentOutOfRangeException thrown from Setup.Unpack inside
DatReaderWriter when WB calls TryGet<Setup>(stab.Id, ...) on a stab id
whose prefix is GfxObj (0x01xxxxxx), not Setup (0x02xxxxxx).
DatReaderWriter finds the file in Portal's tree (GfxObjs and Setups
share tree-lookups), attempts to parse GfxObj bytes as Setup format,
throws OOR. Exception bubbles to PrepareMeshData's outer try/catch
which silently swallows + returns null. Entire cell fails to upload.

This commit lands the diagnostic infrastructure that surfaced the bug:

- WbMeshAdapter: replaced NullLogger<ObjectMeshManager> with a small
  Console-backed ConsoleErrorLogger<T> private class. Filters to
  LogLevel.Error+. WB's existing _logger.LogError(ex, ...) at the
  swallow site now writes [wb-error] lines with type + message + top 5
  stack frames. Bridges WB's intentional log point to acdream's console.
- WbMeshAdapter: extended [indoor-upload] NULL_RESULT probe with
  reader-divergence diagnostic (ourCellDb.TryGet, wbResolveId.Count,
  wbSelectedType, wbDbIsPortal, wbDbTryGet<EnvCell>, hadRenderData).
  Made it possible to rule out cache-hits and reader-divergence as
  causes before identifying the real one.
- Cause report at docs/research/2026-05-19-indoor-cell-rendering-cause.md
  documents the full chain: 55 ArgumentOutOfRangeException stack traces
  captured in one launch, all from PrepareEnvCellMeshData line 1223.

The fix itself (1-line guard at WB's TryGet<Setup> call site) is applied
to references/WorldBuilder/.../ObjectMeshManager.cs — which is a git
submodule. Will be committed separately to the WB submodule after
visual verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 13:00:18 +02:00
parent 914638819d
commit b838eccb38
2 changed files with 157 additions and 4 deletions

View file

@ -77,10 +77,52 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir);
// Phase 2 diagnostic — replace NullLogger with a Console-backed
// logger so WB's internal catch block at ObjectMeshManager.cs:589
// (and similar) surfaces its swallowed exceptions instead of
// dropping them. ConsoleErrorLogger filters to LogLevel.Error+
// so successful operations stay quiet.
_meshManager = new ObjectMeshManager(
_graphicsDevice,
_wbDats,
NullLogger<ObjectMeshManager>.Instance);
new ConsoleErrorLogger<ObjectMeshManager>());
}
/// <summary>
/// Minimal Console-backed logger that fires only on
/// <see cref="LogLevel.Error"/> and above. Format:
/// <code>[wb-error] &lt;message&gt;
/// [wb-error] &lt;ExceptionType&gt;: &lt;ExceptionMessage&gt;
/// [wb-error] at &lt;frame&gt; (up to 5 frames)</code>
/// Used to surface WB's silently-caught exceptions in
/// <c>ObjectMeshManager.PrepareMeshData</c>.
/// </summary>
private sealed class ConsoleErrorLogger<T> : ILogger<T>
{
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
Console.WriteLine($"[wb-error] {message}");
if (exception is not null)
{
Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
var stack = (exception.StackTrace ?? "")
.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Take(5);
foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
}
}
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
private WbMeshAdapter()
@ -150,8 +192,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// [indoor-upload] requested probe — only for EnvCell ids.
if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
{
bool hadRenderDataAtRequest = _meshManager.HasRenderData(id);
_pendingEnvCellRequests.Add(id);
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}");
// Phase 2 — surface what WB's catch block silently swallows.
// ObjectMeshManager.PrepareMeshData has a try/catch at line 589
@ -189,16 +232,32 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
catch { /* swallow — this is best-effort diagnostic */ }
int wbResolveCount = -1;
string wbSelectedType = "none";
bool wbDbTryGetEnvCell = false;
bool wbDbIsPortal = false;
try
{
wbResolveCount = _wbDats?.ResolveId((uint)cellId).Count() ?? -1;
var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList();
wbResolveCount = wbResolutions?.Count ?? -1;
if (wbResolutions is not null && wbResolutions.Count > 0)
{
var selected = wbResolutions
.OrderByDescending(r => r.Database == _wbDats!.Portal)
.First();
wbSelectedType = selected.Type.ToString();
wbDbIsPortal = selected.Database == _wbDats!.Portal;
try { wbDbTryGetEnvCell = selected.Database.TryGet<DatReaderWriter.DBObjs.EnvCell>((uint)cellId, out _); } catch {}
}
}
catch { /* swallow — best-effort */ }
Console.WriteLine(
$"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " +
$"ourCellDb.TryGet={ourCellFound} " +
$"wbResolveId.Count={wbResolveCount}");
$"wbResolveId.Count={wbResolveCount} " +
$"wbSelectedType={wbSelectedType} " +
$"wbDbIsPortal={wbDbIsPortal} " +
$"wbDbTryGet<EnvCell>={wbDbTryGetEnvCell}");
}
}, TaskScheduler.Default);
}