AtmosphericFrame (set 3/binding 5) grows additively from 160 to 192 bytes: two appended vec4 members, uAtmosphereClockWind and uAtmosphereWindAmplitude, carry the foliage-wind clock/weather and amplitude inputs VM6b's shader displacement will read. RenderPackShaderAbi renames the old constant to AtmosphericFrameSizeBytesV1 (160), adds AtmosphericFrameSizeBytesV2 (192), keeps AtmosphericFrameSizeBytes pointing at the current (v2) size, and adds ShaderAbiVersion = 2. RenderPackSpirvValidator.ValidateAtmosphericFrame accepts either the v1 (seven-member, 160-byte) or v2 (nine-member, 192-byte) shape and rejects anything else naming both — this is why the frozen external sample packs under samples/*/Shaders/*.spv, whose GLSL sources are not in this tree, need no rebuild: a v1 shader bound to the 192-byte buffer still reads correctly, since a bound range only needs to be >= the block's own declared size. DirectionalSunShadowRenderer's caster pass now binds AtmosphericFrame too (both the multiview and per-cascade sites), through a new AtmosphericFrameBufferBinding the graph owns and supplies via DirectionalSunShadowRenderInput. AtmosphericPostProcessGraph.RenderDirectionalShadows builds its own 192-byte ring allocation for this, separate from the world receiver's frame block, because the caster pass runs before RenderPostProcess constructs that block within the same frame. The four world caster pipeline variants (opaque/cutout, base/multiview) are now allowed to declare binding 5 in the validator; terrain casters are untouched. This commit is plumbing only: the two new members are always written but never read by any shader yet (zero placeholders), so pack-on and pack-off output are both pixel-identical to before. VM6b wires the real weather-driven values and the shader-side displacement. App hermetic filter: 5972/5974 (2 pre-existing failures unrelated to this change, confirmed against the unmodified baseline). Core.Tests hermetic: 4697/4697. RenderPackValidator.Tests: 30/30. VulkanShaderManifestTests (retail oracle set): 7/7, byte-identical. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
524 lines
20 KiB
C#
524 lines
20 KiB
C#
using AcDream.App.Rendering.Packs;
|
|
using AcDream.Plugin.Abstractions.Rendering;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Packs;
|
|
|
|
public sealed class RenderPackSpirvValidatorTests
|
|
{
|
|
[Fact]
|
|
public void BuiltInDescriptorValidatesSelectedCelestialContract()
|
|
{
|
|
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
|
BuiltInAtmosphericRenderPack.Descriptor,
|
|
RenderPackHostCapabilities.Conformance);
|
|
|
|
Assert.True(result.Success, result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectedCelestialSemanticRequiresAuthoredCelestialCapability()
|
|
{
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
|
descriptor = descriptor with
|
|
{
|
|
RequiredCapabilities = descriptor.RequiredCapabilities
|
|
.Where(static capability =>
|
|
capability != RenderCapability.AuthoredCelestialDirectionalLight)
|
|
.ToArray(),
|
|
};
|
|
|
|
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
|
descriptor,
|
|
RenderPackHostCapabilities.Conformance);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal(
|
|
$"Pack '{descriptor.Id}' declares semantic "
|
|
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' but does not "
|
|
+ $"require capability '{RenderCapability.AuthoredCelestialDirectionalLight}'.",
|
|
result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectionalShadowDepthRejectsSunDirectionAlias()
|
|
{
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderPassDeclaration shadowPass = descriptor.Passes.Single(static pass =>
|
|
pass.Semantic == RenderPassSemantic.DirectionalShadowDepth);
|
|
descriptor = descriptor with
|
|
{
|
|
Passes = descriptor.Passes.Select(pass => ReferenceEquals(pass, shadowPass)
|
|
? pass with
|
|
{
|
|
SemanticInputs = pass.SemanticInputs
|
|
.Append(RenderSemanticInput.SunDirection)
|
|
.ToArray(),
|
|
}
|
|
: pass).ToArray(),
|
|
};
|
|
|
|
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
|
descriptor,
|
|
RenderPackHostCapabilities.Conformance);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Equal(
|
|
$"Directional-shadow pass '{shadowPass.Id}' must declare "
|
|
+ $"'{RenderSemanticInput.SelectedCelestialDirectionalLight}' and must not "
|
|
+ "alias the sun-specific atmospheric direction.",
|
|
result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInPackPassesBinaryShaderInterfaceValidation()
|
|
{
|
|
RenderPackValidationResult result = RenderPackValidator.ValidateSelectedAssets(
|
|
BuiltInAtmosphericRenderPack.Descriptor,
|
|
BuiltInAtmosphericRenderPack.CreateAssets(SpirvDirectory()));
|
|
|
|
Assert.True(result.Success, result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectionalShadowUniformRequiresSixMembersAndSelectedSourceAtOffset320()
|
|
{
|
|
byte[] valid = Shader("directional_shadow_world_opaque.vert.spv");
|
|
PipelineVariantDeclaration variant = BuiltInAtmosphericRenderPack.Descriptor
|
|
.PipelineVariants.Single(static value =>
|
|
value.Semantic
|
|
== RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster);
|
|
|
|
Assert.Equal(336, RenderPackShaderAbi.DirectionalShadowSizeBytes);
|
|
Assert.Equal(
|
|
6,
|
|
BlockMemberCount(
|
|
valid,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.DirectionalShadowBinding));
|
|
Assert.Equal(
|
|
320u,
|
|
BlockMemberOffset(
|
|
valid,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.DirectionalShadowBinding,
|
|
member: 5));
|
|
RenderPackSpirvValidationResult baseline =
|
|
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
|
valid,
|
|
RenderPackShaderStage.Vertex,
|
|
variant);
|
|
Assert.True(baseline.Success, baseline.Reason);
|
|
|
|
byte[] wrongOffset = valid.ToArray();
|
|
MutateBlockMemberOffset(
|
|
wrongOffset,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.DirectionalShadowBinding,
|
|
member: 5,
|
|
replacement: 304);
|
|
RenderPackSpirvValidationResult offsetResult =
|
|
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
|
wrongOffset,
|
|
RenderPackShaderStage.Vertex,
|
|
variant);
|
|
Assert.False(offsetResult.Success);
|
|
Assert.Contains("336-byte ABI v1", offsetResult.Reason, StringComparison.Ordinal);
|
|
|
|
byte[] fiveMembers = RemoveLastBlockMember(
|
|
valid,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.DirectionalShadowBinding);
|
|
RenderPackSpirvValidationResult memberCountResult =
|
|
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
|
fiveMembers,
|
|
RenderPackShaderStage.Vertex,
|
|
variant);
|
|
Assert.False(memberCountResult.Success);
|
|
Assert.Contains("336-byte ABI v1", memberCountResult.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void FullscreenShaderCannotReadReservedSetZero()
|
|
{
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePassShader(
|
|
Shader("directional_shadow_world_opaque.vert.spv"),
|
|
RenderPackShaderStage.Vertex,
|
|
Pass());
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("set 0 binding 0", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void FullscreenShaderCannotAliasRetailSetOne()
|
|
{
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePassShader(
|
|
Shader("terrain_atmospheric.vert.spv"),
|
|
RenderPackShaderStage.Vertex,
|
|
Pass(inputs:
|
|
[
|
|
RenderSemanticInput.Weather,
|
|
RenderSemanticInput.SelectedCelestialDirectionalLight,
|
|
]));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("set 1 binding", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 4u)]
|
|
[InlineData(6, 112u)]
|
|
public void WrongAtmosphericUniformOffsetOrSizeIsRejected(int member, uint replacement)
|
|
{
|
|
byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv");
|
|
MutateBlockMemberOffset(
|
|
spirv,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.AtmosphericFrameBinding,
|
|
member,
|
|
replacement);
|
|
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePassShader(
|
|
spirv,
|
|
RenderPackShaderStage.Fragment,
|
|
Pass(inputs:
|
|
[
|
|
RenderSemanticInput.SceneDepth,
|
|
RenderSemanticInput.SunScreenPosition,
|
|
RenderSemanticInput.Weather,
|
|
]));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("AtmosphericFrame", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
// Campaign VM VM6: atmospheric_common.glsl's AtmosphericFrame block grew
|
|
// from ABI v1 (seven members, 160 bytes) to ABI v2 (nine members, 192
|
|
// bytes) — additive only. Every compiled shader under this tree that
|
|
// includes the header (atmospheric_sun_occlusion.frag among them) is now
|
|
// v2; the frozen external sample packs under samples/*/Shaders/*.spv
|
|
// have no GLSL source in this tree and are never recompiled, so they
|
|
// stay v1 forever. The validator must accept both shapes and reject
|
|
// anything else, naming both.
|
|
|
|
[Fact]
|
|
public void AtmosphericFrameAbiV2ModuleIsValid()
|
|
{
|
|
byte[] spirv = Shader("atmospheric_sun_occlusion.frag.spv");
|
|
|
|
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
|
|
spirv,
|
|
RenderPackShaderStage.Fragment,
|
|
Pass(inputs:
|
|
[
|
|
RenderSemanticInput.SceneDepth,
|
|
RenderSemanticInput.SunScreenPosition,
|
|
RenderSemanticInput.Weather,
|
|
]));
|
|
|
|
Assert.True(result.Success, result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void AtmosphericFrameAbiV1ModuleIsStillValid()
|
|
{
|
|
byte[] v2 = Shader("atmospheric_sun_occlusion.frag.spv");
|
|
byte[] v1 = RemoveLastBlockMember(
|
|
RemoveLastBlockMember(
|
|
v2,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.AtmosphericFrameBinding),
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.AtmosphericFrameBinding);
|
|
|
|
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
|
|
v1,
|
|
RenderPackShaderStage.Fragment,
|
|
Pass(inputs:
|
|
[
|
|
RenderSemanticInput.SceneDepth,
|
|
RenderSemanticInput.SunScreenPosition,
|
|
RenderSemanticInput.Weather,
|
|
]));
|
|
|
|
Assert.True(result.Success, result.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void AtmosphericFrameEightMemberModuleIsRejected()
|
|
{
|
|
byte[] v2 = Shader("atmospheric_sun_occlusion.frag.spv");
|
|
byte[] eightMembers = RemoveLastBlockMember(
|
|
v2,
|
|
RenderPackShaderAbi.UniformDescriptorSet,
|
|
RenderPackShaderAbi.AtmosphericFrameBinding);
|
|
|
|
RenderPackSpirvValidationResult result = RenderPackSpirvValidator.ValidatePassShader(
|
|
eightMembers,
|
|
RenderPackShaderStage.Fragment,
|
|
Pass(inputs:
|
|
[
|
|
RenderSemanticInput.SceneDepth,
|
|
RenderSemanticInput.SunScreenPosition,
|
|
RenderSemanticInput.Weather,
|
|
]));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("ABI v1", result.Reason, StringComparison.Ordinal);
|
|
Assert.Contains("ABI v2", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeclaredStageAndMainEntryPointAreEnforced()
|
|
{
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePassShader(
|
|
Shader("atmospheric_sun_rays.frag.spv"),
|
|
RenderPackShaderStage.Vertex,
|
|
Pass(inputs: [RenderSemanticInput.FrameTime], reads: ["mask"]));
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("entry point 'main'", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void SampledTextureTableRequiresDeclaredSemanticOrResourceInput()
|
|
{
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePassShader(
|
|
Shader("atmospheric_bloom_downsample.frag.spv"),
|
|
RenderPackShaderStage.Fragment,
|
|
Pass());
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("sampled without a declared", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void WritableRendererStorageIsRejectedEvenForAnAllowedBaseRole()
|
|
{
|
|
byte[] spirv = Shader("directional_shadow_world_opaque.vert.spv");
|
|
RemoveNonWritableDecoration(spirv, set: 0, binding: 0);
|
|
var variant = new PipelineVariantDeclaration(
|
|
"world-caster",
|
|
RenderPipelineBaseSemantic.WorldMesh,
|
|
"world.vert.spv",
|
|
"world.frag.spv",
|
|
RenderMaterialClass.Opaque,
|
|
[RenderSemanticInput.ShadowCasterTransforms])
|
|
{
|
|
Semantic = RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster,
|
|
};
|
|
|
|
RenderPackSpirvValidationResult result =
|
|
RenderPackSpirvValidator.ValidatePipelineVariantShader(
|
|
spirv,
|
|
RenderPackShaderStage.Vertex,
|
|
variant);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("storage writes are forbidden", result.Reason, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static RenderPassDeclaration Pass(
|
|
IReadOnlyList<RenderSemanticInput>? inputs = null,
|
|
IReadOnlyList<string>? reads = null) => new(
|
|
"pass",
|
|
RenderPassHook.ToneMap,
|
|
"pass.vert.spv",
|
|
"pass.frag.spv",
|
|
inputs ?? [],
|
|
reads ?? [],
|
|
[]);
|
|
|
|
private static byte[] Shader(string name) => File.ReadAllBytes(Path.Combine(SpirvDirectory(), name));
|
|
|
|
private static string SpirvDirectory() => Path.Combine(
|
|
RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv");
|
|
|
|
private static string RepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName
|
|
?? throw new InvalidOperationException("Could not locate the repository root.");
|
|
}
|
|
|
|
private static void MutateBlockMemberOffset(
|
|
byte[] spirv,
|
|
uint set,
|
|
uint binding,
|
|
int member,
|
|
uint replacement)
|
|
{
|
|
uint[] words = Words(spirv);
|
|
uint variable = DescriptorVariable(words, set, binding);
|
|
uint pointer = VariableResultType(words, variable);
|
|
uint structure = PointerPointee(words, pointer);
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
uint opcode = words[index] & 0xffff;
|
|
if (opcode == 72 && count >= 5
|
|
&& words[index + 1] == structure
|
|
&& words[index + 2] == (uint)member
|
|
&& words[index + 3] == 35)
|
|
{
|
|
words[index + 4] = replacement;
|
|
CopyBack(words, spirv);
|
|
return;
|
|
}
|
|
}
|
|
throw new InvalidOperationException("Target block member offset was not found.");
|
|
}
|
|
|
|
private static void RemoveNonWritableDecoration(byte[] spirv, uint set, uint binding)
|
|
{
|
|
uint[] words = Words(spirv);
|
|
uint variable = DescriptorVariable(words, set, binding);
|
|
uint pointer = VariableResultType(words, variable);
|
|
uint structure = PointerPointee(words, pointer);
|
|
bool mutated = false;
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
uint opcode = words[index] & 0xffff;
|
|
if (opcode == 71 && count >= 3
|
|
&& words[index + 1] == variable
|
|
&& words[index + 2] == 24)
|
|
{
|
|
words[index + 2] = 23; // Coherent, so the descriptor is no longer readonly.
|
|
mutated = true;
|
|
}
|
|
else if (opcode == 72 && count >= 4
|
|
&& words[index + 1] == structure
|
|
&& words[index + 3] == 24)
|
|
{
|
|
words[index + 3] = 23;
|
|
mutated = true;
|
|
}
|
|
}
|
|
if (!mutated)
|
|
throw new InvalidOperationException("Target NonWritable decoration was not found.");
|
|
CopyBack(words, spirv);
|
|
}
|
|
|
|
private static int BlockMemberCount(byte[] spirv, uint set, uint binding)
|
|
{
|
|
uint[] words = Words(spirv);
|
|
uint structure = DescriptorStructure(words, set, binding);
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
if ((words[index] & 0xffff) == 30 && count >= 2 && words[index + 1] == structure)
|
|
return count - 2;
|
|
}
|
|
throw new InvalidOperationException("Descriptor block structure was not found.");
|
|
}
|
|
|
|
private static uint BlockMemberOffset(
|
|
byte[] spirv,
|
|
uint set,
|
|
uint binding,
|
|
int member)
|
|
{
|
|
uint[] words = Words(spirv);
|
|
uint structure = DescriptorStructure(words, set, binding);
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
if ((words[index] & 0xffff) == 72
|
|
&& count >= 5
|
|
&& words[index + 1] == structure
|
|
&& words[index + 2] == (uint)member
|
|
&& words[index + 3] == 35)
|
|
{
|
|
return words[index + 4];
|
|
}
|
|
}
|
|
throw new InvalidOperationException("Descriptor block member offset was not found.");
|
|
}
|
|
|
|
private static byte[] RemoveLastBlockMember(byte[] spirv, uint set, uint binding)
|
|
{
|
|
uint[] words = Words(spirv);
|
|
uint structure = DescriptorStructure(words, set, binding);
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
if ((words[index] & 0xffff) != 30
|
|
|| count < 3
|
|
|| words[index + 1] != structure)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var mutated = words.ToList();
|
|
mutated[index] = ((uint)(count - 1) << 16) | 30u;
|
|
mutated.RemoveAt(index + count - 1);
|
|
var bytes = new byte[mutated.Count * sizeof(uint)];
|
|
Buffer.BlockCopy(mutated.ToArray(), 0, bytes, 0, bytes.Length);
|
|
return bytes;
|
|
}
|
|
throw new InvalidOperationException("Descriptor block structure was not found.");
|
|
}
|
|
|
|
private static uint DescriptorStructure(uint[] words, uint set, uint binding)
|
|
{
|
|
uint variable = DescriptorVariable(words, set, binding);
|
|
uint pointer = VariableResultType(words, variable);
|
|
return PointerPointee(words, pointer);
|
|
}
|
|
|
|
private static uint DescriptorVariable(uint[] words, uint set, uint binding)
|
|
{
|
|
var sets = new Dictionary<uint, uint>();
|
|
var bindings = new Dictionary<uint, uint>();
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
uint opcode = words[index] & 0xffff;
|
|
if (opcode != 71 || count < 4)
|
|
continue;
|
|
if (words[index + 2] == 34) sets[words[index + 1]] = words[index + 3];
|
|
if (words[index + 2] == 33) bindings[words[index + 1]] = words[index + 3];
|
|
}
|
|
return sets.Keys.Single(id => sets[id] == set && bindings.GetValueOrDefault(id) == binding);
|
|
}
|
|
|
|
private static uint VariableResultType(uint[] words, uint variable)
|
|
{
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
if ((words[index] & 0xffff) == 59 && count >= 4 && words[index + 2] == variable)
|
|
return words[index + 1];
|
|
}
|
|
throw new InvalidOperationException("Descriptor variable was not found.");
|
|
}
|
|
|
|
private static uint PointerPointee(uint[] words, uint pointer)
|
|
{
|
|
for (int index = 5; index < words.Length; index += checked((int)(words[index] >> 16)))
|
|
{
|
|
int count = checked((int)(words[index] >> 16));
|
|
if ((words[index] & 0xffff) == 32 && count >= 4 && words[index + 1] == pointer)
|
|
return words[index + 3];
|
|
}
|
|
throw new InvalidOperationException("Descriptor pointer type was not found.");
|
|
}
|
|
|
|
private static uint[] Words(byte[] bytes)
|
|
{
|
|
var words = new uint[bytes.Length / 4];
|
|
Buffer.BlockCopy(bytes, 0, words, 0, bytes.Length);
|
|
return words;
|
|
}
|
|
|
|
private static void CopyBack(uint[] words, byte[] bytes) =>
|
|
Buffer.BlockCopy(words, 0, bytes, 0, bytes.Length);
|
|
}
|