diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
new file mode 100644
index 0000000..4e6e325
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadata.cs
@@ -0,0 +1,21 @@
+using AcDream.Core.Meshing;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// AC-specific surface render metadata that WB's MeshBatchData
+/// doesn't carry. Computed at mesh-extraction time and looked up by the
+/// draw dispatcher to drive translucency / sky-pass / fog behavior.
+///
+///
+/// All fields mirror those on today's so
+/// behavior is preserved bit-for-bit through the migration.
+///
+///
+public sealed record AcSurfaceMetadata(
+ TranslucencyKind Translucency,
+ float Luminosity,
+ float Diffuse,
+ float SurfOpacity,
+ bool NeedsUvRepeat,
+ bool DisableFog);
diff --git a/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
new file mode 100644
index 0000000..20b9278
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/AcSurfaceMetadataTable.cs
@@ -0,0 +1,27 @@
+using System.Collections.Concurrent;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Thread-safe side-table mapping (gfxObjId, surfaceIdx) to
+/// . Populated when a GfxObj's mesh data
+/// is extracted; queried at draw time.
+///
+///
+/// Keyed by (gfxObjId, surfaceIdx) not by WB's runtime batch
+/// identity because batch objects can be evicted and re-loaded by WB's
+/// LRU; the (gfxObj, surface) pair is stable across cycles.
+///
+///
+public sealed class AcSurfaceMetadataTable
+{
+ private readonly ConcurrentDictionary<(ulong gfxObjId, int surfaceIdx), AcSurfaceMetadata> _table = new();
+
+ public void Add(ulong gfxObjId, int surfaceIdx, AcSurfaceMetadata meta)
+ => _table[(gfxObjId, surfaceIdx)] = meta;
+
+ public bool TryLookup(ulong gfxObjId, int surfaceIdx, out AcSurfaceMetadata meta)
+ => _table.TryGetValue((gfxObjId, surfaceIdx), out meta!);
+
+ public void Clear() => _table.Clear();
+}
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
new file mode 100644
index 0000000..23aa231
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs
@@ -0,0 +1,72 @@
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.Meshing;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public sealed class AcSurfaceMetadataTableTests
+{
+ [Fact]
+ public void Add_ThenLookup_RoundTripsSameMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var meta = new AcSurfaceMetadata(
+ Translucency: TranslucencyKind.AlphaBlend,
+ Luminosity: 0.5f,
+ Diffuse: 0.8f,
+ SurfOpacity: 0.7f,
+ NeedsUvRepeat: true,
+ DisableFog: false);
+
+ table.Add(gfxObjId: 0x01000123ul, surfaceIdx: 2, meta);
+
+ Assert.True(table.TryLookup(0x01000123ul, 2, out var got));
+ Assert.Equal(meta, got);
+ }
+
+ [Fact]
+ public void Lookup_MissingKey_ReturnsFalse()
+ {
+ var table = new AcSurfaceMetadataTable();
+ Assert.False(table.TryLookup(0xDEADBEEFul, 0, out _));
+ }
+
+ [Fact]
+ public void Add_OverwritesPreviousMetadata()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var first = new AcSurfaceMetadata(TranslucencyKind.Opaque, 0f, 1f, 1f, false, false);
+ var second = new AcSurfaceMetadata(TranslucencyKind.Additive, 1f, 1f, 1f, false, true);
+
+ table.Add(0xAAAA, 0, first);
+ table.Add(0xAAAA, 0, second);
+
+ Assert.True(table.TryLookup(0xAAAA, 0, out var got));
+ Assert.Equal(second, got);
+ }
+
+ [Fact]
+ public void Add_FromMultipleThreads_IsThreadSafe()
+ {
+ var table = new AcSurfaceMetadataTable();
+ var threads = new System.Threading.Tasks.Task[8];
+ for (int t = 0; t < 8; t++)
+ {
+ int threadIdx = t;
+ threads[t] = System.Threading.Tasks.Task.Run(() =>
+ {
+ for (int i = 0; i < 1000; i++)
+ {
+ ulong key = (ulong)(threadIdx * 1000 + i);
+ table.Add(key, 0, new AcSurfaceMetadata(
+ TranslucencyKind.Opaque, 0f, 1f, 1f, false, false));
+ }
+ });
+ }
+ System.Threading.Tasks.Task.WaitAll(threads);
+
+ // 8000 entries should be present.
+ for (int t = 0; t < 8; t++)
+ for (int i = 0; i < 1000; i++)
+ Assert.True(table.TryLookup((ulong)(t * 1000 + i), 0, out _));
+ }
+}