diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index a7d163fe..a18aaa7e 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -6488,6 +6488,32 @@ public static partial class AgcExports
TraceAstroTitlePixelGlobalProbe(pixelEvaluation);
}
+ // Patch BufferFormat from the attrib table onto the V# before host
+ // vertex input. IR discovery often keeps a stale float format from the
+ // unpatched sharp — that turns UI glyphs into gradient triangles.
+ // Match by stride+offset (not bare base address) so interleaved streams
+ // keep loading-video bindings intact.
+ if (exportEvaluation.VertexInputs is { Count: > 0 } discoveredInputs &&
+ AgcVertexMetadata.TryGetVertexTableRegisters(
+ ctx,
+ exportShaderAddress,
+ exportShaderHeader,
+ out var vertexTables))
+ {
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ exportEvaluation.ScalarRegisters,
+ vertexTables,
+ discoveredInputs);
+ if (!ReferenceEquals(merged, discoveredInputs))
+ {
+ TraceAgcShader(
+ $"agc.vertex_metadata_format es=0x{exportShaderAddress:X16} " +
+ $"count={merged.Count}");
+ exportEvaluation = exportEvaluation with { VertexInputs = merged };
+ }
+ }
+
// Every bound color target the shader exports to. Deferred renderers
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
// Fall back to slot 0 if we cannot match any export to a bound target.
@@ -7269,6 +7295,7 @@ public static partial class AgcExports
Mix(input.NumberFormat);
Mix(input.Stride);
Mix(input.OffsetBytes);
+ Mix(input.PerInstance ? 1u : 0u);
}
}
@@ -8277,7 +8304,8 @@ public static partial class AgcExports
binding.OffsetBytes,
binding.Data,
binding.DataLength,
- binding.DataPooled);
+ binding.DataPooled,
+ binding.PerInstance);
}
return buffers;
diff --git a/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs b/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs
new file mode 100644
index 00000000..8537ef27
--- /dev/null
+++ b/src/SharpEmu.Libs/Agc/AgcVertexMetadata.cs
@@ -0,0 +1,788 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using SharpEmu.HLE;
+using SharpEmu.ShaderCompiler;
+
+namespace SharpEmu.Libs.Agc;
+
+///
+/// AGC embedded vertex metadata. Locates
+/// PtrVertexBufferTable / PtrVertexAttribDescTable and builds authoritative
+/// attribute layouts that draw translation merges onto IR-discovered fetches.
+///
+internal static class AgcVertexMetadata
+{
+ private const ushort IllegalDirectOffset = 0xFFFF;
+ private const ulong ShaderUserDataOffset = 0x08;
+ private const ulong ShaderInputSemanticsOffset = 0x30;
+ private const ulong ShaderNumInputSemanticsOffset = 0x50;
+
+ internal enum AgcDirectResourceType : uint
+ {
+ PtrVertexBufferTable = 8,
+ PtrVertexAttribDescTable = 10,
+ Last = PtrVertexAttribDescTable,
+ }
+
+ internal readonly record struct VertexTableRegisters(
+ int VertexBufferReg,
+ int VertexAttribReg,
+ uint InputSemanticsCount,
+ ulong InputSemanticsAddress);
+
+ ///
+ /// One AGC attrib-table resource.
+ /// Representation: is the V# base; attribute byte
+ /// offset is applied as (Vulkan bind offset),
+ /// not folded into the base — avoids double-counting when the IR prolog
+ /// already bumped the sharp address.
+ ///
+ internal readonly record struct MetadataVertexResource(
+ uint Location,
+ uint Semantic,
+ uint HardwareMapping,
+ uint SizeInElements,
+ ulong SharpBase,
+ uint Stride,
+ uint OffsetBytes,
+ uint DataFormat,
+ uint NumberFormat,
+ uint ComponentCount,
+ bool PerInstance);
+
+ ///
+ /// Reads AGC user-data direct-resource offsets for the ES header mapped to
+ /// . Returns false when the header is
+ /// unknown or the tables are absent (attribute-less clears).
+ ///
+ internal static bool TryGetVertexTableRegisters(
+ CpuContext ctx,
+ ulong shaderCodeAddress,
+ ulong shaderHeaderAddress,
+ out VertexTableRegisters registers)
+ {
+ registers = new VertexTableRegisters(-1, -1, 0, 0);
+ if (shaderHeaderAddress == 0 ||
+ !TryReadUInt64(ctx, shaderHeaderAddress + ShaderUserDataOffset, out var userDataAddress) ||
+ userDataAddress == 0)
+ {
+ return false;
+ }
+
+ // ShaderUserData layout:
+ // 0x00: uint16_t* direct_resource_offset
+ // 0x08: sharp_resource_offset[4]
+ // 0x28: eud_size_dw, srt_size_dw
+ // 0x2C: direct_resource_count
+ if (!TryReadUInt64(ctx, userDataAddress, out var directResourceOffset) ||
+ !TryReadUInt16(ctx, userDataAddress + 0x2C, out var directResourceCount))
+ {
+ return false;
+ }
+
+ var maxTypes = (uint)AgcDirectResourceType.Last + 1u;
+ if (directResourceCount > maxTypes || directResourceOffset == 0)
+ {
+ return false;
+ }
+
+ var vertexBufferReg = -1;
+ var vertexAttribReg = -1;
+ for (uint type = 0; type < directResourceCount; type++)
+ {
+ if (!TryReadUInt16(
+ ctx,
+ directResourceOffset + (type * sizeof(ushort)),
+ out var reg) ||
+ reg == IllegalDirectOffset)
+ {
+ continue;
+ }
+
+ switch ((AgcDirectResourceType)type)
+ {
+ case AgcDirectResourceType.PtrVertexBufferTable:
+ vertexBufferReg = reg;
+ break;
+ case AgcDirectResourceType.PtrVertexAttribDescTable:
+ vertexAttribReg = reg;
+ break;
+ }
+ }
+
+ if (vertexBufferReg < 0 || vertexAttribReg < 0)
+ {
+ return false;
+ }
+
+ if (!TryReadUInt64(
+ ctx,
+ shaderHeaderAddress + ShaderInputSemanticsOffset,
+ out var inputSemanticsAddress) ||
+ !TryReadUInt32(
+ ctx,
+ shaderHeaderAddress + ShaderNumInputSemanticsOffset,
+ out var inputSemanticsCount) ||
+ inputSemanticsCount == 0 ||
+ inputSemanticsAddress == 0)
+ {
+ return false;
+ }
+
+ registers = new VertexTableRegisters(
+ vertexBufferReg,
+ vertexAttribReg,
+ inputSemanticsCount,
+ inputSemanticsAddress);
+ return true;
+ }
+
+ ///
+ /// Builds attrib resources from AGC input_semantics + tables.
+ /// ShaderSemantic packing:
+ /// bits [7:0] semantic → attrib table index
+ /// bits [15:8] hardware_mapping → VGPR destination
+ /// bits [19:16] size_in_elements
+ ///
+ internal static bool TryBuildVertexResourcesFromMetadata(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ out IReadOnlyList resources)
+ {
+ resources = Array.Empty();
+ if (tables.VertexAttribReg < 0 ||
+ tables.VertexBufferReg < 0 ||
+ tables.VertexAttribReg + 1 >= scalarRegisters.Count ||
+ tables.VertexBufferReg + 1 >= scalarRegisters.Count ||
+ tables.InputSemanticsCount == 0)
+ {
+ return false;
+ }
+
+ var attribTable =
+ ((ulong)scalarRegisters[tables.VertexAttribReg + 1] << 32) |
+ scalarRegisters[tables.VertexAttribReg];
+ var bufferTable =
+ ((ulong)scalarRegisters[tables.VertexBufferReg + 1] << 32) |
+ scalarRegisters[tables.VertexBufferReg];
+ if (attribTable == 0 || bufferTable == 0)
+ {
+ return false;
+ }
+
+ var built = new List((int)tables.InputSemanticsCount);
+ for (uint i = 0; i < tables.InputSemanticsCount; i++)
+ {
+ if (!TryReadUInt32(
+ ctx,
+ tables.InputSemanticsAddress + (i * sizeof(uint)),
+ out var semanticWord))
+ {
+ return false;
+ }
+
+ // Attrib index is semantic bits [7:0], not hardware_mapping.
+ var semantic = semanticWord & 0xFFu;
+ var hardwareMapping = (semanticWord >> 8) & 0xFFu;
+ var sizeInElements = (semanticWord >> 16) & 0xFu;
+ if (!TryReadUInt32(ctx, attribTable + (semantic * sizeof(uint)), out var attribWord))
+ {
+ return false;
+ }
+
+ // Attrib dword: buffer index [4:0], format [13:5], offset [25:14], fetch [26].
+ var bufferIndex = attribWord & 0x1Fu;
+ var format = (attribWord >> 5) & 0x1FFu;
+ var offset = (attribWord >> 14) & 0xFFFu;
+ var fetchIndex = (attribWord >> 26) & 0x1u;
+ var sharpAddress = bufferTable + (bufferIndex * 16u);
+ if (!TryReadUInt32(ctx, sharpAddress, out var sharp0) ||
+ !TryReadUInt32(ctx, sharpAddress + 4, out var sharp1))
+ {
+ return false;
+ }
+
+ var sharpBase = sharp0 | ((ulong)(sharp1 & 0xFFFFu) << 32);
+ var stride = (sharp1 >> 16) & 0x3FFFu;
+ if (sharpBase == 0 || stride == 0)
+ {
+ continue;
+ }
+
+ var fallbackComponents = sizeInElements != 0 ? sizeInElements : 4u;
+ var (dataFormat, numberFormat, components) =
+ MapAttribFormat(format, fallbackComponents);
+ built.Add(new MetadataVertexResource(
+ Location: i,
+ Semantic: semantic,
+ HardwareMapping: hardwareMapping,
+ SizeInElements: sizeInElements,
+ SharpBase: sharpBase,
+ Stride: stride,
+ OffsetBytes: offset,
+ DataFormat: dataFormat,
+ NumberFormat: numberFormat,
+ ComponentCount: components,
+ PerInstance: fetchIndex != 0));
+ }
+
+ if (built.Count == 0)
+ {
+ return false;
+ }
+
+ resources = built;
+ return true;
+ }
+
+ ///
+ /// Patch IR-discovered fetches from the attrib table onto the V# format/offset.
+ /// Prefer 1:1 Location pairing when counts match on one interleaved stream
+ /// (GTA UI glyphs). Otherwise match by stride + byte offset. Never rebases
+ /// BaseAddress/Data/Location/Pc/PerInstance.
+ ///
+ internal static IReadOnlyList MergeVertexInputsFromMetadata(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ IReadOnlyList discovered)
+ {
+ if (discovered.Count == 0 ||
+ !TryBuildVertexResourcesFromMetadata(
+ ctx,
+ scalarRegisters,
+ tables,
+ out var resources))
+ {
+ return discovered;
+ }
+
+ if (TryMergeByLocationPairing(discovered, resources, out var paired))
+ {
+ return paired;
+ }
+
+ var merged = new List(discovered.Count);
+ var usedResources = new bool[resources.Count];
+ var changed = false;
+ foreach (var input in discovered)
+ {
+ if (!TryMatchMetadataResource(input, resources, usedResources, out var resource, out var fillOffset))
+ {
+ merged.Add(input);
+ continue;
+ }
+
+ var refined = ApplyMetadataFormat(input, resource, fillOffset);
+ changed |= refined != input;
+ merged.Add(refined);
+ }
+
+ return changed ? merged : discovered;
+ }
+
+ ///
+ /// When discovery and metadata describe the same interleaved stream with
+ /// equal attribute counts, pair by sorted Location (semantic order).
+ /// Keeps each binding's Pc/Location for SPIR-V; overlays format + offset.
+ ///
+ private static bool TryMergeByLocationPairing(
+ IReadOnlyList discovered,
+ IReadOnlyList resources,
+ out IReadOnlyList merged)
+ {
+ merged = discovered;
+ if (discovered.Count != resources.Count || discovered.Count == 0)
+ {
+ return false;
+ }
+
+ var orderedInputs = discovered.OrderBy(static input => input.Location).ToArray();
+ var orderedResources = resources.OrderBy(static resource => resource.Location).ToArray();
+ var streamBase = orderedResources[0].SharpBase;
+ var streamStride = orderedResources[0].Stride;
+ for (var index = 0; index < orderedResources.Length; index++)
+ {
+ var resource = orderedResources[index];
+ var input = orderedInputs[index];
+ if (resource.SharpBase != streamBase ||
+ resource.Stride != streamStride ||
+ (input.Stride != 0 && input.Stride != streamStride) ||
+ !IsSameVertexStream(input, resource))
+ {
+ return false;
+ }
+ }
+
+ var byPc = new Dictionary(discovered.Count);
+ var changed = false;
+ for (var index = 0; index < orderedInputs.Length; index++)
+ {
+ var input = orderedInputs[index];
+ var resource = orderedResources[index];
+ var fillOffset = input.BaseAddress == resource.SharpBase ||
+ IsAddressInsideCapturedSpan(input, resource.SharpBase);
+ var refined = ApplyMetadataFormat(input, resource, fillOffset);
+ changed |= refined != input;
+ byPc[input.Pc] = refined;
+ }
+
+ if (!changed)
+ {
+ return false;
+ }
+
+ var result = new Gen5VertexInputBinding[discovered.Count];
+ for (var index = 0; index < discovered.Count; index++)
+ {
+ result[index] = byPc[discovered[index].Pc];
+ }
+
+ merged = result;
+ return true;
+ }
+
+ private static Gen5VertexInputBinding ApplyMetadataFormat(
+ Gen5VertexInputBinding input,
+ MetadataVertexResource resource,
+ bool fillOffsetBytes)
+ {
+ var components = input.ComponentCount != 0 &&
+ input.ComponentCount < resource.ComponentCount
+ ? input.ComponentCount
+ : resource.ComponentCount;
+
+ return input with
+ {
+ DataFormat = resource.DataFormat,
+ NumberFormat = resource.NumberFormat,
+ ComponentCount = components,
+ OffsetBytes = fillOffsetBytes ? resource.OffsetBytes : input.OffsetBytes,
+ };
+ }
+
+ ///
+ /// Legacy entry point — forwards to .
+ ///
+ internal static IReadOnlyList RefineVertexInputs(
+ CpuContext ctx,
+ IReadOnlyList scalarRegisters,
+ VertexTableRegisters tables,
+ IReadOnlyList discovered) =>
+ MergeVertexInputsFromMetadata(ctx, scalarRegisters, tables, discovered);
+
+ ///
+ /// Collects SBufferLoad / SLoad PCs that read the AGC attrib or buffer
+ /// tables (embedded-fetch prolog). Those loads are executed on the
+ /// CPU during scalar evaluation; once vertex inputs are bound they must
+ /// not run again as live SSBOs on the GPU.
+ ///
+ internal static HashSet CollectFetchPrologPcs(
+ Gen5ShaderProgram program,
+ VertexTableRegisters tables)
+ {
+ var pcs = new HashSet();
+ if (tables.VertexAttribReg < 0 || tables.VertexBufferReg < 0)
+ {
+ return pcs;
+ }
+
+ var tableRegs = new HashSet
+ {
+ (uint)tables.VertexAttribReg,
+ (uint)tables.VertexAttribReg + 1u,
+ (uint)tables.VertexBufferReg,
+ (uint)tables.VertexBufferReg + 1u,
+ };
+
+ foreach (var instruction in program.Instructions)
+ {
+ var isScalarLoad =
+ instruction.Opcode.StartsWith("SBufferLoad", StringComparison.Ordinal) ||
+ instruction.Opcode.StartsWith("SLoad", StringComparison.Ordinal);
+ if (!isScalarLoad)
+ {
+ continue;
+ }
+
+ // SMEM loads encode the scalar base pointer in Sources[0].
+ if (instruction.Sources.Count > 0 &&
+ instruction.Sources[0] is
+ {
+ Kind: Gen5OperandKind.ScalarRegister,
+ Value: var scalarBase,
+ } &&
+ tableRegs.Contains(scalarBase))
+ {
+ pcs.Add(instruction.Pc);
+ continue;
+ }
+
+ if (instruction.Control is Gen5BufferMemoryControl buffer &&
+ tableRegs.Contains(buffer.ScalarResource))
+ {
+ pcs.Add(instruction.Pc);
+ }
+ }
+
+ return pcs;
+ }
+
+ private static bool TryMatchMetadataResource(
+ Gen5VertexInputBinding input,
+ IReadOnlyList resources,
+ bool[] usedResources,
+ out MetadataVertexResource resource,
+ out bool fillOffsetBytes)
+ {
+ resource = default;
+ fillOffsetBytes = false;
+ var bestScore = int.MinValue;
+ var bestIndex = -1;
+ var bestFillOffset = false;
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (usedResources[index])
+ {
+ continue;
+ }
+
+ var candidate = resources[index];
+ if (candidate.Stride != 0 &&
+ input.Stride != 0 &&
+ candidate.Stride != input.Stride)
+ {
+ continue;
+ }
+
+ if (!IsSameVertexStream(input, candidate))
+ {
+ continue;
+ }
+
+ var attrAddress = candidate.SharpBase + candidate.OffsetBytes;
+ var score = int.MinValue;
+ var fillOffset = false;
+
+ // Post-capture interleaved: shared BaseAddress, distinct OffsetBytes.
+ if (input.OffsetBytes == candidate.OffsetBytes &&
+ (input.BaseAddress == candidate.SharpBase ||
+ IsAddressInsideCapturedSpan(input, candidate.SharpBase)))
+ {
+ score = 400;
+ }
+ // IR prolog baked attrib offset into the V# base.
+ else if (input.BaseAddress == attrAddress)
+ {
+ score = 350;
+ }
+ // Discovery never saw the attrib offset — only safe when this
+ // resource's offset uniquely identifies it among unused entries.
+ else if (input.BaseAddress == candidate.SharpBase &&
+ input.OffsetBytes == 0 &&
+ candidate.OffsetBytes != 0 &&
+ IsUniqueUnusedOffset(resources, usedResources, candidate.OffsetBytes, index))
+ {
+ score = 300;
+ fillOffset = true;
+ }
+ else if (input.BaseAddress == candidate.SharpBase &&
+ input.OffsetBytes == 0 &&
+ candidate.OffsetBytes == 0)
+ {
+ score = 250;
+ }
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestIndex = index;
+ bestFillOffset = fillOffset;
+ }
+ }
+
+ // Require an offset-aware match. Bare SharpBase ties (score 250) are
+ // only accepted when a single unused resource remains for that stream.
+ if (bestIndex < 0 || bestScore < 300)
+ {
+ if (bestIndex < 0 || bestScore < 250)
+ {
+ return false;
+ }
+
+ var unusedSameStream = 0;
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (!usedResources[index] && IsSameVertexStream(input, resources[index]))
+ {
+ unusedSameStream++;
+ }
+ }
+
+ if (unusedSameStream != 1)
+ {
+ return false;
+ }
+ }
+
+ usedResources[bestIndex] = true;
+ resource = resources[bestIndex];
+ fillOffsetBytes = bestFillOffset;
+ return true;
+ }
+
+ private static bool IsSameVertexStream(
+ Gen5VertexInputBinding input,
+ MetadataVertexResource resource)
+ {
+ if (input.BaseAddress == resource.SharpBase ||
+ input.BaseAddress == resource.SharpBase + resource.OffsetBytes)
+ {
+ return true;
+ }
+
+ return IsAddressInsideCapturedSpan(input, resource.SharpBase);
+ }
+
+ private static bool IsAddressInsideCapturedSpan(
+ Gen5VertexInputBinding input,
+ ulong address) =>
+ input.DataLength > 0 &&
+ address >= input.BaseAddress &&
+ address < input.BaseAddress + (ulong)input.DataLength;
+
+ private static bool IsUniqueUnusedOffset(
+ IReadOnlyList resources,
+ bool[] usedResources,
+ uint offsetBytes,
+ int candidateIndex)
+ {
+ for (var index = 0; index < resources.Count; index++)
+ {
+ if (index == candidateIndex || usedResources[index])
+ {
+ continue;
+ }
+
+ if (resources[index].OffsetBytes == offsetBytes)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Attrib-table format
+ /// fields are VertexAttribFormat; V# / Vulkan paths need BufferFormat.
+ /// Unknown values pass through (already BufferFormat).
+ ///
+ private static uint VertexAttribFormatToBufferFormat(uint format) =>
+ format switch
+ {
+ 0 => 0, // Invalid
+ 4 => 1, // k8UNorm
+ 8 => 2, // k8SNorm
+ 12 => 3, // k8UScaled
+ 16 => 4, // k8SScaled
+ 20 => 5, // k8UInt
+ 24 => 6, // k8SInt
+ 28 => 7, // k16UNorm
+ 32 => 8, // k16SNorm
+ 36 => 9, // k16UScaled
+ 40 => 10, // k16SScaled
+ 44 => 11, // k16UInt
+ 48 => 12, // k16SInt
+ 52 => 13, // k16Float
+ 57 => 14, // k8_8UNorm
+ 61 => 15, // k8_8SNorm
+ 65 => 16, // k8_8UScaled
+ 69 => 17, // k8_8SScaled
+ 73 => 18, // k8_8UInt
+ 77 => 19, // k8_8SInt
+ 80 => 20, // k32UInt
+ 84 => 21, // k32SInt
+ 88 => 22, // k32Float
+ 93 => 23, // k16_16UNorm
+ 97 => 24, // k16_16SNorm
+ 101 => 25, // k16_16UScaled
+ 105 => 26, // k16_16SScaled
+ 109 => 27, // k16_16UInt
+ 113 => 28, // k16_16SInt
+ 117 => 29, // k16_16Float
+ 122 => 30, // k11_11_10UNorm
+ 126 => 31,
+ 130 => 32,
+ 134 => 33,
+ 138 => 34,
+ 142 => 35,
+ 146 => 36,
+ 150 => 37, // k10_11_11UNorm
+ 154 => 38,
+ 158 => 39,
+ 162 => 40,
+ 166 => 41,
+ 170 => 42,
+ 174 => 43,
+ 179 => 44, // k2_10_10_10UNorm
+ 183 => 45,
+ 187 => 46,
+ 191 => 47,
+ 195 => 48,
+ 199 => 49,
+ 203 => 50, // k10_10_10_2UNorm
+ 207 => 51,
+ 211 => 52,
+ 215 => 53,
+ 219 => 54,
+ 223 => 55,
+ 227 => 56, // k8_8_8_8UNorm
+ 231 => 57,
+ 235 => 58,
+ 239 => 59,
+ 243 => 60,
+ 247 => 61,
+ 249 => 62, // k32_32UInt
+ 253 => 63,
+ 257 => 64, // k32_32Float
+ 263 => 65, // k16_16_16_16UNorm
+ 267 => 66,
+ 271 => 67,
+ 275 => 68,
+ 279 => 69,
+ 283 => 70,
+ 287 => 71, // k16_16_16_16Float
+ 290 => 72, // k32_32_32UInt
+ 294 => 73,
+ 298 => 74,
+ 303 => 75, // k32_32_32_32UInt
+ 307 => 76,
+ 311 => 77, // k32_32_32_32Float
+ _ => format,
+ };
+
+ ///
+ /// Maps Prospero attrib-table formats onto GNM (DataFormat, NumberFormat,
+ /// Components) for ToVkVertexFormat. Accepts VertexAttribFormat
+ /// or BufferFormat (pass-through). NumberFormat: 0 Unorm, 1 SNorm,
+ /// 2 UScaled, 3 SScaled, 4 UInt, 5 SInt, 7 Float.
+ ///
+ private static (uint DataFormat, uint NumberFormat, uint Components) MapAttribFormat(
+ uint attribFormat,
+ uint fallbackComponents)
+ {
+ // Prospero VertexAttribFormat quirks before BufferFormat conversion.
+ if (attribFormat == 113)
+ {
+ return (14, 7, 4); // R32G32B32A32_SFLOAT
+ }
+
+ if (attribFormat == 121)
+ {
+ return (5, 7, 2); // R16G16_SFLOAT
+ }
+
+ var bufferFormat = VertexAttribFormatToBufferFormat(attribFormat);
+
+ // Prospero::BufferFormat numeric values (gpu_defs.h).
+ return bufferFormat switch
+ {
+ 1 => (1, 0, 1), // k8UNorm
+ 2 => (1, 1, 1), // k8SNorm
+ 3 => (1, 2, 1), // k8UScaled
+ 4 => (1, 3, 1), // k8SScaled
+ 5 => (1, 4, 1), // k8UInt
+ 6 => (1, 5, 1), // k8SInt
+ 7 => (2, 0, 1), // k16UNorm
+ 8 => (2, 1, 1), // k16SNorm
+ 9 => (2, 2, 1), // k16UScaled
+ 10 => (2, 3, 1), // k16SScaled
+ 11 => (2, 4, 1), // k16UInt
+ 12 => (2, 5, 1), // k16SInt
+ 13 => (2, 7, 1), // k16Float
+ 14 => (3, 0, 2), // k8_8UNorm
+ 15 => (3, 1, 2), // k8_8SNorm
+ 16 => (3, 2, 2), // k8_8UScaled
+ 17 => (3, 3, 2), // k8_8SScaled
+ 18 => (3, 4, 2), // k8_8UInt
+ 19 => (3, 5, 2), // k8_8SInt
+ 20 => (4, 4, 1), // k32UInt
+ 21 => (4, 5, 1), // k32SInt
+ 22 => (4, 7, 1), // k32Float
+ 23 => (5, 0, 2), // k16_16UNorm
+ 24 => (5, 1, 2), // k16_16SNorm
+ 25 => (5, 2, 2), // k16_16UScaled
+ 26 => (5, 3, 2), // k16_16SScaled
+ 27 => (5, 4, 2), // k16_16UInt
+ 28 => (5, 5, 2), // k16_16SInt
+ 29 => (5, 7, 2), // k16_16Float
+ 50 => (9, 0, 4), // k10_10_10_2UNorm
+ 51 => (9, 1, 4), // k10_10_10_2SNorm
+ 56 => (10, 0, 4), // k8_8_8_8UNorm
+ 57 => (10, 1, 4), // k8_8_8_8SNorm
+ 58 => (10, 2, 4), // k8_8_8_8UScaled
+ 59 => (10, 3, 4), // k8_8_8_8SScaled
+ 60 => (10, 4, 4), // k8_8_8_8UInt
+ 61 => (10, 5, 4), // k8_8_8_8SInt
+ 62 => (11, 4, 2), // k32_32UInt
+ 63 => (11, 5, 2), // k32_32SInt
+ 64 => (11, 7, 2), // k32_32Float
+ 65 => (12, 0, 4), // k16_16_16_16UNorm
+ 66 => (12, 1, 4), // k16_16_16_16SNorm
+ 67 => (12, 2, 4), // k16_16_16_16UScaled
+ 68 => (12, 3, 4), // k16_16_16_16SScaled
+ 69 => (12, 4, 4), // k16_16_16_16UInt
+ 70 => (12, 5, 4), // k16_16_16_16SInt
+ 71 => (12, 7, 4), // k16_16_16_16Float
+ 72 => (13, 4, 3), // k32_32_32UInt
+ 73 => (13, 5, 3), // k32_32_32SInt
+ 74 => (13, 7, 3), // k32_32_32Float
+ 75 => (14, 4, 4), // k32_32_32_32UInt
+ 76 => (14, 5, 4), // k32_32_32_32SInt
+ 77 => (14, 7, 4), // k32_32_32_32Float
+ _ => (14, 7, Math.Clamp(fallbackComponents, 1u, 4u)),
+ };
+ }
+
+ private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
+ {
+ Span buffer = stackalloc byte[2];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer);
+ return true;
+ }
+
+ private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
+ {
+ Span buffer = stackalloc byte[4];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer);
+ return true;
+ }
+
+ private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
+ {
+ Span buffer = stackalloc byte[8];
+ if (!ctx.Memory.TryRead(address, buffer))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buffer);
+ return true;
+ }
+}
diff --git a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
index 91a2ea3f..ab18d9a0 100644
--- a/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
+++ b/src/SharpEmu.Libs/Gpu/GuestGpuTypes.cs
@@ -76,7 +76,8 @@ internal sealed record GuestVertexBuffer(
uint OffsetBytes,
byte[] Data,
int Length,
- bool Pooled);
+ bool Pooled,
+ bool PerInstance = false);
internal sealed record GuestIndexBuffer(
byte[] Data,
diff --git a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
index 86740ef0..ffcef9d8 100644
--- a/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
+++ b/src/SharpEmu.Libs/Gpu/Metal/MetalVideoPresenter.Draws.cs
@@ -1225,8 +1225,11 @@ internal static partial class MetalVideoPresenter
? vertexBuffer.Stride
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
- // MTLVertexStepFunction.PerVertex = 1.
- MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
+ // MTLVertexStepFunction: PerVertex = 1, PerInstance = 2.
+ MetalNative.Send(
+ layout,
+ MetalNative.Selector("setStepFunction:"),
+ vertexBuffer.PerInstance ? 2 : 1);
}
return descriptor;
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index e2ecd73e..f1f50bc4 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -3170,6 +3170,7 @@ internal static unsafe class VulkanVideoPresenter
public uint NumberFormat;
public uint Stride;
public uint OffsetBytes;
+ public bool PerInstance;
}
private const Format DepthFormat = Format.D32Sfloat;
@@ -6658,33 +6659,47 @@ internal static unsafe class VulkanVideoPresenter
PName = entryPoint,
};
- var vertexBindingDescriptions =
- new VertexInputBindingDescription[resources.VertexBuffers.Length];
+ // One Vulkan binding per unique host buffer and input rate
+ // (fetch_index). Attributes share that binding with
+ // Offset = OffsetBytes.
+ var bindingByBuffer = new Dictionary<(ulong Handle, bool PerInstance), uint>();
+ var vertexBindingList = new List();
var vertexAttributeDescriptions =
new VertexInputAttributeDescription[resources.VertexBuffers.Length];
for (var index = 0; index < resources.VertexBuffers.Length; index++)
{
var vertexBuffer = resources.VertexBuffers[index];
- vertexBindingDescriptions[index] = new VertexInputBindingDescription
+ var bufferKey = (vertexBuffer.Buffer.Handle, vertexBuffer.PerInstance);
+ if (!bindingByBuffer.TryGetValue(bufferKey, out var bindingIndex))
{
- Binding = (uint)index,
- Stride = vertexBuffer.Stride == 0
- ? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
- : vertexBuffer.Stride,
- InputRate = VertexInputRate.Vertex,
- };
+ bindingIndex = (uint)vertexBindingList.Count;
+ bindingByBuffer[bufferKey] = bindingIndex;
+ vertexBindingList.Add(new VertexInputBindingDescription
+ {
+ Binding = bindingIndex,
+ Stride = vertexBuffer.Stride == 0
+ ? Math.Max(vertexBuffer.ComponentCount, 1) * sizeof(float)
+ : vertexBuffer.Stride,
+ InputRate = vertexBuffer.PerInstance
+ ? VertexInputRate.Instance
+ : VertexInputRate.Vertex,
+ });
+ }
+
vertexAttributeDescriptions[index] = new VertexInputAttributeDescription
{
Location = vertexBuffer.Location,
- Binding = (uint)index,
+ Binding = bindingIndex,
Format = ToVkVertexFormat(
vertexBuffer.DataFormat,
vertexBuffer.NumberFormat,
vertexBuffer.ComponentCount),
- Offset = 0,
+ Offset = vertexBuffer.OffsetBytes,
};
}
+ var vertexBindingDescriptions = vertexBindingList.ToArray();
+
fixed (VertexInputBindingDescription* vertexBindingPointerBase = vertexBindingDescriptions)
fixed (VertexInputAttributeDescription* vertexAttributePointerBase = vertexAttributeDescriptions)
{
@@ -9280,6 +9295,7 @@ internal static unsafe class VulkanVideoPresenter
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
+ PerInstance = guestBuffer.PerInstance,
};
}
@@ -9297,6 +9313,7 @@ internal static unsafe class VulkanVideoPresenter
NumberFormat = guestBuffer.NumberFormat,
Stride = guestBuffer.Stride,
OffsetBytes = guestBuffer.OffsetBytes,
+ PerInstance = guestBuffer.PerInstance,
};
private VkBuffer CreateHostBuffer(
@@ -9398,21 +9415,28 @@ internal static unsafe class VulkanVideoPresenter
private static Format ToVkVertexFormat(
uint dataFormat,
uint numberFormat,
- uint componentCount) =>
- (dataFormat, numberFormat) switch
+ uint componentCount)
+ {
+ var format = (dataFormat, numberFormat) switch
{
(1, 0) => Format.R8Unorm,
(1, 1) => Format.R8SNorm,
+ (1, 2) => Format.R8Uscaled,
+ (1, 3) => Format.R8Sscaled,
(1, 4) => Format.R8Uint,
(1, 5) => Format.R8Sint,
(1, 9) => Format.R8Srgb,
(2, 0) => Format.R16Unorm,
(2, 1) => Format.R16SNorm,
+ (2, 2) => Format.R16Uscaled,
+ (2, 3) => Format.R16Sscaled,
(2, 4) => Format.R16Uint,
(2, 5) => Format.R16Sint,
(2, 7) => Format.R16Sfloat,
(3, 0) => Format.R8G8Unorm,
(3, 1) => Format.R8G8SNorm,
+ (3, 2) => Format.R8G8Uscaled,
+ (3, 3) => Format.R8G8Sscaled,
(3, 4) => Format.R8G8Uint,
(3, 5) => Format.R8G8Sint,
(3, 9) => Format.R8G8Srgb,
@@ -9467,6 +9491,9 @@ internal static unsafe class VulkanVideoPresenter
(14, 4) => Format.R32G32B32A32Uint,
(14, 5) => Format.R32G32B32A32Sint,
(14, 7) => Format.R32G32B32A32Sfloat,
+ // Prospero VertexAttribFormat quirks also seen as buffer formats.
+ (113, _) => Format.R32G32B32A32Sfloat,
+ (121, _) => Format.R16G16Sfloat,
(16, 0) => Format.B5G6R5UnormPack16,
(17, 0) => Format.R5G5B5A1UnormPack16,
(19, 0) => Format.R4G4B4A4UnormPack16,
@@ -9474,6 +9501,38 @@ internal static unsafe class VulkanVideoPresenter
_ => ToVkFloatVertexFormat(componentCount),
};
+ return NarrowVkVertexFormat(format, componentCount);
+ }
+
+ ///
+ /// Narrow a sharp's full VkFormat to the component count the VS fetch
+ /// actually consumes.
+ ///
+ private static Format NarrowVkVertexFormat(Format format, uint usedComponents)
+ {
+ if (usedComponents == 0)
+ {
+ return format;
+ }
+
+ return (format, usedComponents) switch
+ {
+ (Format.R32G32B32A32Sfloat, 1) => Format.R32Sfloat,
+ (Format.R32G32B32A32Sfloat, 2) => Format.R32G32Sfloat,
+ (Format.R32G32B32A32Sfloat, 3) => Format.R32G32B32Sfloat,
+ (Format.R32G32B32Sfloat, 1) => Format.R32Sfloat,
+ (Format.R32G32B32Sfloat, 2) => Format.R32G32Sfloat,
+ (Format.R16G16B16A16Sfloat, 1) => Format.R16Sfloat,
+ (Format.R16G16B16A16Sfloat, 2) => Format.R16G16Sfloat,
+ (Format.R8G8B8A8Unorm, 1) => Format.R8Unorm,
+ (Format.R8G8B8A8Unorm, 2) => Format.R8G8Unorm,
+ (Format.R8G8B8A8SNorm, 2) => Format.R8G8SNorm,
+ (Format.R8G8B8A8Uint, 1) => Format.R8Uint,
+ (Format.R8G8B8A8Uint, 2) => Format.R8G8Uint,
+ _ => format,
+ };
+ }
+
private static Format ToVkFloatVertexFormat(uint componentCount) =>
componentCount switch
{
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
index d00a6e89..19c01505 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderIr.cs
@@ -312,7 +312,8 @@ public sealed record Gen5VertexInputBinding(
uint OffsetBytes,
byte[] Data,
int DataLength,
- bool DataPooled);
+ bool DataPooled,
+ bool PerInstance = false);
public sealed record Gen5ShaderEvaluation(
IReadOnlyList InitialScalarRegisters,
diff --git a/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs b/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
index 369ecf1b..6c5e9a37 100644
--- a/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
+++ b/src/SharpEmu.ShaderCompiler/Gen5ShaderScalarEvaluator.cs
@@ -883,8 +883,11 @@ public static class Gen5ShaderScalarEvaluator
Gen5ShaderInstruction instruction,
Gen5BufferMemoryControl control,
BufferDescriptor descriptor) =>
+ // AGC embedded fetch is BufferLoadFormat/TBufferLoadFormat with idxen.
+ // offen is allowed: the constant/scalar offset folds into OffsetBytes
+ // (UI glyph shaders use this shape). Rejecting offen left those loads
+ // as live SSBOs and dropped vertex attributes.
control.IndexEnabled &&
- !control.OffsetEnabled &&
control.DwordCount is >= 1 and <= 4 &&
descriptor.BaseAddress != 0 &&
descriptor.Stride != 0 &&
diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs
new file mode 100644
index 00000000..32d1703e
--- /dev/null
+++ b/tests/SharpEmu.Libs.Tests/Agc/AgcVertexMetadataTests.cs
@@ -0,0 +1,293 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System.Buffers.Binary;
+using SharpEmu.HLE;
+using SharpEmu.Libs.Agc;
+using SharpEmu.ShaderCompiler;
+using Xunit;
+
+namespace SharpEmu.Libs.Tests.Agc;
+
+///
+/// Coverage for AGC attrib-table → BufferFormat merge and semantic indexing.
+///
+public sealed class AgcVertexMetadataTests
+{
+ [Fact]
+ public void BuildVertexResources_UsesSemanticNotHardwareMappingAsAttribIndex()
+ {
+ // input_semantics[0]: semantic=1, hardware_mapping=4, size=2
+ // If hardware_mapping were wrongly used as the attrib index, we'd read
+ // attrib[4] instead of attrib[1] and get the wrong format/offset.
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ // ShaderSemantic word: semantic=1, hw_mapping=4, size_in_elements=2
+ WriteUInt32(memory, semanticsAddress, 1u | (4u << 8) | (2u << 16));
+
+ // attrib[0] unused garbage
+ WriteUInt32(memory, attribTable, 0xDEAD_BEEFu);
+ // attrib[1]: buffer=0, format=k16_16Float(29), offset=8, fetch=0
+ WriteUInt32(memory, attribTable + 4, 0u | (29u << 5) | (8u << 14));
+
+ // V# at buffer table[0]: base=sharpBase, stride=16
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(
+ memory,
+ bufferTable + 4,
+ (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[8] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[9] = (uint)(attribTable >> 32);
+ scalars[10] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[11] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 10,
+ VertexAttribReg: 8,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ Assert.True(
+ AgcVertexMetadata.TryBuildVertexResourcesFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ out var resources));
+ Assert.Single(resources);
+ Assert.Equal(1u, resources[0].Semantic);
+ Assert.Equal(4u, resources[0].HardwareMapping);
+ Assert.Equal(8u, resources[0].OffsetBytes);
+ Assert.Equal(5u, resources[0].DataFormat); // R16G16
+ Assert.Equal(7u, resources[0].NumberFormat); // Float
+ Assert.Equal(2u, resources[0].ComponentCount);
+ Assert.Equal(sharpBase, resources[0].SharpBase);
+ Assert.False(resources[0].PerInstance);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_OverlaysFormatWithoutRebasingCapture()
+ {
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ // format k8_8_8_8UNorm(56), offset=12
+ WriteUInt32(memory, attribTable, 0u | (56u << 5) | (12u << 14));
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ Pc: 0x40,
+ Location: 0,
+ ComponentCount: 4,
+ DataFormat: 14, // wrong IR guess
+ NumberFormat: 7,
+ BaseAddress: sharpBase,
+ Stride: 16,
+ OffsetBytes: 0,
+ Data: data,
+ DataLength: data.Length,
+ DataPooled: false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Single(merged);
+ Assert.Equal(0u, merged[0].Location);
+ Assert.Equal(sharpBase, merged[0].BaseAddress);
+ Assert.Same(data, merged[0].Data);
+ Assert.Equal(10u, merged[0].DataFormat); // RGBA8
+ Assert.Equal(0u, merged[0].NumberFormat); // Unorm
+ Assert.Equal(12u, merged[0].OffsetBytes);
+ Assert.Equal(0x40u, merged[0].Pc);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_AcceptsVertexAttribFormatEnums()
+ {
+ // Attrib tables store VertexAttribFormat (227 = rgba8 unorm), not
+ // BufferFormat (56). Without conversion the format patch is a no-op.
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ WriteUInt32(memory, attribTable, 0u | (227u << 5) | (12u << 14)); // VertexAttribFormat
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ 0x40, 0, 4, 14, 7, sharpBase, 16, 12, data, data.Length, false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Equal(10u, merged[0].DataFormat);
+ Assert.Equal(0u, merged[0].NumberFormat);
+ Assert.Equal(12u, merged[0].OffsetBytes);
+ }
+
+ [Fact]
+ public void MergeVertexInputs_MatchesInterleavedAttrsByOffsetNotBareBase()
+ {
+ // Both attributes share SharpBase. Matching by base alone would assign
+ // the color format to position (video/UI regression).
+ const ulong memoryBase = 0x1_0000_0000;
+ var memory = new FakeCpuMemory(memoryBase, 0x2000);
+ var ctx = new CpuContext(memory, Generation.Gen5);
+
+ const ulong semanticsAddress = memoryBase + 0x100;
+ const ulong attribTable = memoryBase + 0x200;
+ const ulong bufferTable = memoryBase + 0x300;
+ const ulong sharpBase = memoryBase + 0x800;
+
+ // semantic0 → pos float4 @0; semantic1 → color rgba8 @12
+ WriteUInt32(memory, semanticsAddress, 0u | (0u << 8) | (4u << 16));
+ WriteUInt32(memory, semanticsAddress + 4, 1u | (4u << 8) | (4u << 16));
+ WriteUInt32(memory, attribTable, 0u | (77u << 5) | (0u << 14)); // k32_32_32_32Float
+ WriteUInt32(memory, attribTable + 4, 0u | (56u << 5) | (12u << 14)); // rgba8unorm @12
+ WriteUInt32(memory, bufferTable, (uint)(sharpBase & 0xFFFF_FFFFUL));
+ WriteUInt32(memory, bufferTable + 4, (uint)(sharpBase >> 32) | (16u << 16));
+
+ var scalars = new uint[32];
+ scalars[4] = (uint)(attribTable & 0xFFFF_FFFFUL);
+ scalars[5] = (uint)(attribTable >> 32);
+ scalars[6] = (uint)(bufferTable & 0xFFFF_FFFFUL);
+ scalars[7] = (uint)(bufferTable >> 32);
+
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 6,
+ VertexAttribReg: 4,
+ InputSemanticsCount: 2,
+ InputSemanticsAddress: semanticsAddress);
+
+ var data = new byte[64];
+ var discovered = new[]
+ {
+ new Gen5VertexInputBinding(
+ 0x40, 0, 4, 14, 7, sharpBase, 16, 0, data, data.Length, false),
+ new Gen5VertexInputBinding(
+ 0x80, 1, 4, 14, 7, sharpBase, 16, 12, data, data.Length, false),
+ };
+
+ var merged = AgcVertexMetadata.MergeVertexInputsFromMetadata(
+ ctx,
+ scalars,
+ tables,
+ discovered);
+ Assert.Equal(2, merged.Count);
+ Assert.Equal(0u, merged[0].OffsetBytes);
+ Assert.Equal(12u, merged[1].OffsetBytes);
+ Assert.Equal(0u, merged[1].NumberFormat); // Unorm color, not float
+ Assert.Equal(10u, merged[1].DataFormat); // RGBA8
+ Assert.Equal(sharpBase, merged[0].BaseAddress);
+ Assert.Equal(sharpBase, merged[1].BaseAddress);
+ Assert.Same(data, merged[0].Data);
+ }
+
+ [Fact]
+ public void CollectFetchPrologPcs_FindsSBufferLoadsFromTableRegisters()
+ {
+ var tables = new AgcVertexMetadata.VertexTableRegisters(
+ VertexBufferReg: 10,
+ VertexAttribReg: 8,
+ InputSemanticsCount: 1,
+ InputSemanticsAddress: 1);
+
+ var program = new Gen5ShaderProgram(
+ 0,
+ [
+ new Gen5ShaderInstruction(
+ 0x10,
+ Gen5ShaderEncoding.Smem,
+ "SBufferLoadDword",
+ Words: [],
+ Sources: [Gen5Operand.Scalar(8)],
+ Destinations: [Gen5Operand.Scalar(20)],
+ new Gen5ScalarMemoryControl(1, 0, null)),
+ new Gen5ShaderInstruction(
+ 0x20,
+ Gen5ShaderEncoding.Smem,
+ "SBufferLoadDword",
+ Words: [],
+ Sources: [Gen5Operand.Scalar(12)],
+ Destinations: [Gen5Operand.Scalar(24)],
+ new Gen5ScalarMemoryControl(1, 0, null)),
+ new Gen5ShaderInstruction(
+ 0x30,
+ Gen5ShaderEncoding.Sopp,
+ "SEndpgm",
+ Words: [],
+ Sources: [],
+ Destinations: [],
+ null),
+ ]);
+
+ var pcs = AgcVertexMetadata.CollectFetchPrologPcs(program, tables);
+ Assert.Contains(0x10u, pcs);
+ Assert.DoesNotContain(0x20u, pcs);
+ }
+
+ private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
+ Assert.True(memory.TryWrite(address, bytes));
+ }
+}