Gliner/Gpu/GlinerGpuCapabilities.cs
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using Sandbox;
namespace GlinerPoc.Gpu;
/// <summary>
/// Static helper shared by the Phase 8A GPU probes: resolves the ComputeShader
/// constructor path form against the live engine and verifies it with a canary
/// dispatch+readback (the ctor alone does NOT throw on a bad path — the engine
/// only logs material-system warnings and every later dispatch is a no-op).
/// </summary>
public static class GlinerGpuShaderLoader
{
private static string _resolvedPrefix;
// Path form that works: EXACTLY as Facepunch's gpu-voxels library uses it —
// "Shaders/voxels/modification_cs.shader": relative to Assets, real folder
// case, WITH the .shader extension.
//
// CRITICAL pitfall (decompiled from Material.FromShader, engine 26.09.22):
// the material created from the path is cached by a normalized name
// (__shader__<lowercased path without extension>.vmat). Every variant of
// the same path — leading slash, different case, missing extension — maps
// to the SAME cached material, and the cache lives in statics that survive
// play restarts and hotloads. One bad first attempt (e.g. a leading-slash
// path, which FixupResourceName rejects) poisons every later attempt with
// a correct path until the editor is restarted. Therefore: try exactly ONE
// well-formed candidate, and only fall back to a DIFFERENTLY-NAMED path.
private static readonly string[] CandidatePrefixes =
{
"Shaders/gliner/",
"assets/shaders/gliner/",
};
/// <summary>
/// Clears the cached path form. MUST be called at the start of every probe
/// run: s&box hotload patches assemblies in place and static fields
/// SURVIVE — a prefix cached by an older (buggy) code version otherwise
/// poisons the next run.
/// </summary>
public static void Reset()
{
_resolvedPrefix = null;
}
public static ComputeShader Create( string shaderFileName )
{
if ( _resolvedPrefix is null )
{
ComputeShader canary = ResolveWithCanary();
if ( shaderFileName == "gliner_vec_add.shader" )
{
return canary;
}
}
return new ComputeShader( _resolvedPrefix + shaderFileName );
}
private static ComputeShader ResolveWithCanary()
{
const string canaryFile = "gliner_vec_add.shader";
foreach ( string prefix in CandidatePrefixes )
{
{
string candidate = prefix + canaryFile;
try
{
var shader = new ComputeShader( candidate );
var (ok, diag) = CanaryVerify( shader );
if ( ok )
{
_resolvedPrefix = prefix;
Log.Info( $"[GLI:GPU] shader path resolved + canary-verified: '{candidate}'" );
return shader;
}
Log.Warning( $"[GLI:GPU] shader path candidate '{candidate}' constructed but canary dispatch FAILED ({diag})" );
}
catch ( Exception e )
{
Log.Warning( $"[GLI:GPU] shader path candidate '{candidate}' threw {e.GetType().Name}: {e.Message}" );
}
}
}
throw new InvalidOperationException(
$"[GLI:ERROR] No candidate path produced a working compute shader for '{canaryFile}'." );
}
/// <summary>Proves the shader actually executes: out[i] == i + 1 bit-exact.
/// Returns (ok, diagnostic) — the diagnostic shows what the GPU actually
/// returned so a silent no-op dispatch is distinguishable from wrong math.</summary>
private static (bool, string) CanaryVerify( ComputeShader shader )
{
const int N = 8;
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i;
}
inBuf.SetData( input );
outBuf.Clear( 0xdeadbeef ); // poison so untouched output is visible
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
shader.Attributes.Set( "AddConstant", 1f );
shader.Dispatch( N, 1, 1 );
var res = new float[N];
outBuf.GetData( res, 0, N );
int bad = 0;
var first = new System.Text.StringBuilder();
for ( int i = 0; i < N; i++ )
{
if ( BitConverter.SingleToInt32Bits( res[i] ) != BitConverter.SingleToInt32Bits( i + 1f ) )
{
bad++;
if ( first.Length < 80 )
{
_ = first.Append( $"[{i}]={res[i]:0.###e-00} " );
}
}
}
if ( bad == 0 )
{
return (true, "all 8 exact");
}
return (false, $"bad={bad}/{N} firstVals={first}");
}
}
/// <summary>
/// Phase 8A.4/8A.5/8A.6/8A.7 — isolated GPU capability probe. Proves, inside
/// the normal s&box play-mode runtime on the current engine:
///
/// compute shader creation + dispatch • FP32 C#→GPU upload • GPU→C# sync and
/// async readback • buffer persistence across repeated dispatches/frames •
/// multi-MB buffers • dispatch/readback overhead • worker-thread dispatch
/// behaviour • basic failure paths • GPU memory stats where exposed.
///
/// This probe is deliberately separate from all production neural classes; it
/// touches no model resources and does not affect the CPU workbench.
/// </summary>
[Title( "GLiNER GPU Capabilities" )]
[Category( "GLiNER" )]
public sealed class GlinerGpuCapabilities : Component
{
[Property]
public bool RunOnStart { get; set; } = true;
private int _passed;
private int _failed;
protected override void OnStart()
{
if ( RunOnStart )
{
_ = RunAsync();
}
}
private async Task RunAsync()
{
var sw = Stopwatch.StartNew();
GlinerGpuShaderLoader.Reset(); // hotload-preserved statics must not poison this run
Log.Info( "[GLI:GPU] capability probe start (engine-managed compute; no external runtimes)" );
try
{
GateOfficialShaderProbe();
GateShaderRoundtrip();
await GateAsyncReadback();
await GateBufferPersistence();
GateDispatchOverhead();
GateLargeBuffer();
await GateWorkerThreadDispatch();
GateErrorPaths();
GateGpuStats();
Log.Info( $"[GLI:GPU] capability probe complete passed={_passed} failed={_failed} total_ms={sw.ElapsedMilliseconds}" );
Log.Info( _failed == 0
? "[GLI:GPU] CAPABILITY ALL PASS"
: $"[GLI:GPU] CAPABILITY FAILURES ({_failed})" );
}
catch ( Exception error )
{
Log.Error( $"[GLI:GPU] capability harness failure: {error}" );
}
}
// ---- definitive differential: Facepunch's own gpu-voxels compute shader ----
// (copied to Assets/Shaders — if THIS fails with the same "bound pipeline
// does not have a compute shader", project compute shaders are broken
// engine-wide on this build, not by our shader authoring.)
private void GateOfficialShaderProbe()
{
// introspect the shader asset first (no dispatch, no crash risk)
try
{
var shaderRes = ResourceLibrary.Get<Shader>( "Shaders/voxels/modification_cs.shader" );
if ( shaderRes is null )
{
Log.Info( "[GLI:GPU] official shader: ResourceLibrary.Get<Shader> returned null" );
}
else
{
Log.Info( $"[GLI:GPU] official shader asset loaded: path={shaderRes.ResourcePath}" );
}
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] official shader asset load threw {e.GetType().Name}: {e.Message}" );
}
// same for OUR shader — introspection only
try
{
var mine = ResourceLibrary.Get<Shader>( "Shaders/gliner/gliner_vec_add.shader" );
Log.Info( mine is null
? "[GLI:GPU] gliner shader: ResourceLibrary.Get<Shader> returned null"
: $"[GLI:GPU] gliner shader asset loaded: path={mine.ResourcePath}" );
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] gliner shader asset load threw {e.GetType().Name}: {e.Message}" );
}
// NOTE: dispatching the official shader was attempted twice with
// complete typed bindings and CRASHED THE EDITOR both times on
// 26.09.22 — the dispatch below is disabled; see the P8A report.
Log.Info( "[GLI:GPU] official voxels dispatch SKIPPED (crashed the editor in two earlier attempts)" );
Gate( "official_shader_probe", false,
"Facepunch modification_cs dispatch crashes the editor process on 26.09.22 (2/2 attempts, complete typed bindings)", "" );
}
// ---- 8A.5: create shader, upload, dispatch, read back --------------------
private void GateShaderRoundtrip()
{
const int N = 1024;
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 0.25f - 128f; // exact fp32 values
}
var expected = new float[N];
for ( int i = 0; i < N; i++ )
{
expected[i] = input[i] + 3.5f;
}
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
var swUpload = Stopwatch.StartNew();
inBuf.SetData( input );
swUpload.Stop();
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
shader.Attributes.Set( "AddConstant", 3.5f );
var swDispatch = Stopwatch.StartNew();
shader.Dispatch( N, 1, 1 );
swDispatch.Stop();
var result = new float[N];
var swReadback = Stopwatch.StartNew();
outBuf.GetData( result, 0, N );
swReadback.Stop();
int bitExact = 0;
for ( int i = 0; i < N; i++ )
{
if ( BitConverter.SingleToInt32Bits( result[i] ) == BitConverter.SingleToInt32Bits( expected[i] ) )
{
bitExact++;
}
}
var m = GlinerPoc.Neural.GlinerMath.Compare( result, expected );
Gate( "vec_roundtrip", m.Pass && bitExact == N,
$"N={N} bitExact={bitExact}/{N} maxAbs={m.MaxAbs:0.###e-00} | " +
$"upload={swUpload.Elapsed.TotalMilliseconds:N3}ms dispatch={swDispatch.Elapsed.TotalMilliseconds:N3}ms " +
$"readback={swReadback.Elapsed.TotalMilliseconds:N3}ms", "" );
}
// ---- 8A.6: asynchronous readback (no frame-thread block) -------------------
//
// Engine facts established live (26.09.22): GpuBuffer.GetDataAsync requires
// an active render context (it reads Graphics.Context); outside a render
// block it throws "IRenderContext was null", and the documented standalone
// context (Graphics.Scope.Create) is not accessible from game code. The
// async pattern available to game code is therefore:
// non-blocking dispatch enqueue (immediate submit outside render blocks,
// ~µs) → game keeps rendering frames → deferred readback of a small
// result buffer once the queue has drained. This gate proves that flow
// and measures both the immediate-readback control (blocks until the GPU
// queue drains) and the deferred readback (cheap, queue already empty).
private async Task GateAsyncReadback()
{
const int N = 4096;
const int Batch = 2000; // heavy batch so the GPU queue has real depth
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = (i % 97) * 0.5f - 24f;
}
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
inBuf.SetData( input );
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
shader.Attributes.Set( "AddConstant", -1.75f );
int mainThread = Environment.CurrentManagedThreadId;
bool graphicsActiveOutsideRenderBlock = Graphics.IsActive;
var result = new float[N];
// (a) GetDataAsync outside a render context: document the constraint live
string asyncApiBehaviour;
try
{
bool callbackFired = false;
outBuf.GetDataAsync( ( ReadOnlySpan<float> data ) => { callbackFired = true; } );
await Task.Delay( 32 );
asyncApiBehaviour = callbackFired
? "GetDataAsync worked outside a render block (callback fired)"
: "GetDataAsync accepted the call but the callback never fired outside a render block";
}
catch ( Exception e )
{
asyncApiBehaviour = $"GetDataAsync outside a render context throws {e.GetType().Name}: {e.Message}";
}
Log.Info( $"[GLI:GPU] async API probe: {asyncApiBehaviour} | Graphics.IsActive outside render block = {graphicsActiveOutsideRenderBlock}" );
// (b) enqueue-only: dispatch a deep batch and measure that the calls
// return immediately (the frame thread is NOT blocked by GPU work)
var swEnqueue = Stopwatch.StartNew();
for ( int i = 0; i < Batch; i++ )
{
shader.Dispatch( N, 1, 1 );
}
swEnqueue.Stop();
double enqueuePerCallMs = swEnqueue.Elapsed.TotalMilliseconds / Batch;
// (c) control: immediate readback right after enqueueing a fresh batch —
// this blocks until the queued GPU work completes
var swImmediate = Stopwatch.StartNew();
outBuf.GetData( result, 0, N );
swImmediate.Stop();
// (d) deferred readback: enqueue another batch, let frames pass, then
// read back — the queue has drained, the readback must be cheap
for ( int i = 0; i < Batch; i++ )
{
shader.Dispatch( N, 1, 1 );
}
await Task.Delay( 250 ); // ~15 frames at 60 Hz — game keeps running
var swDeferred = Stopwatch.StartNew();
outBuf.GetData( result, 0, N );
swDeferred.Stop();
int bitExact = 0;
for ( int i = 0; i < N; i++ )
{
float e = input[i] - 1.75f;
if ( BitConverter.SingleToInt32Bits( result[i] ) == BitConverter.SingleToInt32Bits( e ) )
{
bitExact++;
}
}
Gate( "async_readback", bitExact == N && enqueuePerCallMs < 0.1 && swDeferred.Elapsed.TotalMilliseconds < 10,
$"bitExact={bitExact}/{N} | enqueuePerCall={enqueuePerCallMs:N4}ms (x{Batch} batch, non-blocking) | " +
$"immediateReadbackBlocksMs={swImmediate.Elapsed.TotalMilliseconds:N2} (control: waits for queue) | " +
$"deferredReadbackMs={swDeferred.Elapsed.TotalMilliseconds:N2} (after {250}ms of frames) | mainThread={mainThread}", "" );
}
// ---- 8A.7: buffer persistence (upload once, dispatch many) ----------------
private async Task GateBufferPersistence()
{
const int N = 512;
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 0.125f - 32f;
}
float[] constants = { 0.5f, -1.25f, 100f, 3.5f, -7.5f, 2f, 0f, 42f };
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
inBuf.SetData( input ); // the ONLY upload of inBuf
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
var result = new float[N];
bool allExact = true;
int checks = 0;
foreach ( float c in constants )
{
shader.Attributes.Set( "AddConstant", c );
shader.Dispatch( N, 1, 1 );
outBuf.GetData( result, 0, N );
for ( int i = 0; i < N; i++ )
{
float e = input[i] + c;
checks++;
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( e ) )
{
allExact = false;
}
}
// spread dispatches across frames to prove persistence over time,
// not just within one
await Task.Delay( 16 );
}
Gate( "buffer_persistence", allExact,
$"1 upload, {constants.Length} dispatches across frames, {checks} values bit-exact", "" );
}
// ---- 8A.26: dispatch + readback overhead -----------------------------------
private void GateDispatchOverhead()
{
const int N = 64;
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
var data = new float[N];
inBuf.SetData( data );
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
shader.Attributes.Set( "AddConstant", 1f );
var result = new float[N];
// warmup
for ( int i = 0; i < 10; i++ )
{
shader.Dispatch( N, 1, 1 );
}
outBuf.GetData( result, 0, N );
// enqueue-only cost: dispatches with no readback between them
var sw = Stopwatch.StartNew();
const int Enq = 200;
for ( int i = 0; i < Enq; i++ )
{
shader.Dispatch( N, 1, 1 );
}
sw.Stop();
double enqueueMs = sw.Elapsed.TotalMilliseconds / Enq;
// full cycle: dispatch + synchronous readback
sw.Restart();
const int Cycles = 100;
for ( int i = 0; i < Cycles; i++ )
{
shader.Dispatch( N, 1, 1 );
outBuf.GetData( result, 0, N );
}
sw.Stop();
double cycleMs = sw.Elapsed.TotalMilliseconds / Cycles;
// readback-only on already-resident data
sw.Restart();
const int Reads = 100;
for ( int i = 0; i < Reads; i++ )
{
outBuf.GetData( result, 0, N );
}
sw.Stop();
double readMs = sw.Elapsed.TotalMilliseconds / Reads;
Gate( "dispatch_overhead", true,
$"tiny(64thr) enqueue={enqueueMs:N3}ms fullCycle={cycleMs:N3}ms readbackOnly={readMs:N3}ms", "" );
}
// ---- multi-MB FP32 buffers --------------------------------------------------
private void GateLargeBuffer()
{
const int N = 1_048_576; // 4 MB of FP32
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = (i % 4096) * 0.0625f - 128f; // exact fp32 pattern
}
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
var swUp = Stopwatch.StartNew();
inBuf.SetData( input );
swUp.Stop();
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
shader.Attributes.Set( "AddConstant", 64f );
shader.Dispatch( N, 1, 1 );
var result = new float[N];
var swDown = Stopwatch.StartNew();
outBuf.GetData( result, 0, N );
swDown.Stop();
int bad = 0;
int firstBad = -1;
for ( int i = 0; i < N; i++ )
{
float e = input[i] + 64f;
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( e ) )
{
bad++;
if ( firstBad < 0 )
{
firstBad = i;
}
}
}
Gate( "large_buffer_4mb", bad == 0,
$"N={N} bad={bad} firstBad={firstBad} upload={swUp.Elapsed.TotalMilliseconds:N2}ms readback={swDown.Elapsed.TotalMilliseconds:N2}ms", "" );
}
// ---- 8A.2 evidence: can GPU work be driven from a worker thread? -----------
private async Task GateWorkerThreadDispatch()
{
const int N = 256;
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( N );
using var outBuf = new GpuBuffer<float>( N );
var input = new float[N];
for ( int i = 0; i < N; i++ )
{
input[i] = i * 1.5f;
}
inBuf.SetData( input );
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", N );
string uploadReport = "skipped";
string dispatchReport = "skipped";
bool workerSucceeded = await Task.RunInThreadAsync( () =>
{
int workerId = Environment.CurrentManagedThreadId;
try
{
shader.Attributes.Set( "AddConstant", 0.5f );
}
catch ( Exception e )
{
uploadReport = $"worker {workerId}: Attributes.SetData threw {e.GetType().Name}: {e.Message}";
return false;
}
try
{
shader.Dispatch( N, 1, 1 );
dispatchReport = $"worker {workerId}: Dispatch returned without exception";
}
catch ( Exception e )
{
dispatchReport = $"worker {workerId}: Dispatch threw {e.GetType().Name}: {e.Message}";
return false;
}
return true;
} );
// Whether or not the worker dispatch is legal, verify on the main thread
// that the shader/buffers still work afterwards.
shader.Attributes.Set( "AddConstant", 0.5f );
shader.Dispatch( N, 1, 1 );
var result = new float[N];
outBuf.GetData( result, 0, N );
bool intact = true;
for ( int i = 0; i < N; i++ )
{
if ( BitConverter.SingleToInt32Bits( result[i] ) != BitConverter.SingleToInt32Bits( input[i] + 0.5f ) )
{
intact = false;
break;
}
}
Log.Info( $"[GLI:GPU] worker-thread probe: upload='{uploadReport}' dispatch='{dispatchReport}'" );
Gate( "worker_thread_probe", intact,
$"workerDispatch={(workerSucceeded ? "returned" : "rejected/failed")}; main-thread shader intact after probe; " +
$"dispatchDetail={dispatchReport}", "" );
}
// ---- 8A.32: basic failure paths (never crash the device) --------------------
private void GateErrorPaths()
{
int observed = 0;
// missing shader
try
{
_ = new ComputeShader( "/shaders/gliner/definitely_missing_gliner_probe.shader" );
Log.Info( "[GLI:GPU] errorpath missing-shader: ctor did NOT throw (engine tolerated unknown path)" );
observed++;
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] errorpath missing-shader: ctor threw {e.GetType().Name}: {e.Message}" );
observed++;
}
// disposed buffer readback
try
{
var buf = new GpuBuffer<float>( 16 );
var data = new float[16];
buf.SetData( data );
buf.Dispose();
buf.GetData( data, 0, 16 );
Log.Info( "[GLI:GPU] errorpath disposed-readback: GetData did NOT throw" );
observed++;
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] errorpath disposed-readback: threw {e.GetType().Name}: {e.Message}" );
observed++;
}
// zero-sized dispatch on a valid shader must not kill the device
try
{
var shader = GlinerGpuShaderLoader.Create( "gliner_vec_add.shader" );
using var inBuf = new GpuBuffer<float>( 4 );
using var outBuf = new GpuBuffer<float>( 4 );
var data = new float[] { 1, 2, 3, 4 };
inBuf.SetData( data );
shader.Attributes.Set( "InputValues", inBuf );
shader.Attributes.Set( "OutputValues", outBuf );
shader.Attributes.Set( "ValueCount", 4 );
shader.Attributes.Set( "AddConstant", 0f );
shader.Dispatch( 0, 0, 0 );
var result = new float[4];
outBuf.GetData( result, 0, 4 );
Log.Info( $"[GLI:GPU] errorpath zero-dispatch: no exception; post-dispatch readback ok result0={result[0]}" );
observed++;
// shader still usable afterwards?
shader.Dispatch( 4, 1, 1 );
outBuf.GetData( result, 0, 4 );
bool ok = BitConverter.SingleToInt32Bits( result[0] ) == BitConverter.SingleToInt32Bits( 1f );
Log.Info( $"[GLI:GPU] errorpath zero-dispatch: shader usable after zero dispatch: {ok}" );
observed++;
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] errorpath zero-dispatch: threw {e.GetType().Name}: {e.Message}" );
observed++;
}
Gate( "error_paths", observed >= 3,
$"observed={observed} failure-path behaviours (device survived all)", "" );
}
// ---- 8A.37: GPU memory stats where the engine exposes them ------------------
private void GateGpuStats()
{
try
{
ulong budget = Graphics.VideoMemoryBudget; // WDDM-reported, bytes
ulong used = Graphics.VideoMemoryUsed; // engine render-system allocations
if ( budget == 0 )
{
Log.Info( "[GLI:GPU] vram stats readable but budget reports 0 (not exposed in this context)" );
Gate( "gpu_stats", false, "budget=0 — stats not exposed (non-blocking)", "" );
return;
}
Log.Info( $"[GLI:GPU] vram budget={budget / (1024 * 1024):N0}MiB used={used / (1024 * 1024):N0}MiB" );
Gate( "gpu_stats", true, "Graphics.VideoMemoryBudget/Used readable", "" );
}
catch ( Exception e )
{
Log.Info( $"[GLI:GPU] vram stats unavailable: {e.GetType().Name}: {e.Message}" );
Gate( "gpu_stats", false, "stats API unavailable (non-blocking)", "" );
}
}
// ---- helpers ----------------------------------------------------------------
private void Gate( string name, bool pass, string detail, string extra )
{
if ( pass )
{
_passed++;
Log.Info( $"[GLI:GPU] PASS {name} {detail} {extra}" );
}
else
{
_failed++;
Log.Error( $"[GLI:GPU] FAIL {name} {detail} {extra}" );
}
}
}
// hotload poke 11:57:16