Compare commits
2 Commits
1016_part3
...
xnetcat/st
| Author | SHA1 | Date |
|---|---|---|
|
|
f4a1da601f | |
|
|
164a9918a3 |
|
|
@ -314,6 +314,25 @@ public static partial class Gen5SpirvTranslator
|
|||
}
|
||||
EmitInitialState();
|
||||
|
||||
if (StructuredControlFlow && blocks.Count > 0 && TryStructureRange(blocks, 0, blocks.Count, blocks.Count, emit: false, out _))
|
||||
{
|
||||
// As the dispatcher does before its first block: an out-of-bounds invocation runs nothing.
|
||||
var runLabel = _module.AllocateId();
|
||||
var doneLabel = _module.AllocateId();
|
||||
var isActive = Load(_boolType, _programActive);
|
||||
_module.AddStatement(SpirvOp.SelectionMerge, doneLabel, 0);
|
||||
_module.AddStatement(SpirvOp.BranchConditional, isActive, runLabel, doneLabel);
|
||||
_module.AddLabel(runLabel);
|
||||
if (!TryStructureRange(blocks, 0, blocks.Count, blocks.Count, emit: true, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.Branch, doneLabel);
|
||||
_module.AddLabel(doneLabel);
|
||||
}
|
||||
else
|
||||
{
|
||||
var loopHeader = _module.AllocateId();
|
||||
var switchHeader = _module.AllocateId();
|
||||
var switchMerge = _module.AllocateId();
|
||||
|
|
@ -388,6 +407,7 @@ public static partial class Gen5SpirvTranslator
|
|||
loopHeader,
|
||||
loopMerge);
|
||||
_module.AddLabel(loopMerge);
|
||||
}
|
||||
if (_stage == Gen5SpirvStage.Pixel &&
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_TITLE_SHADER_STATE") == "1" &&
|
||||
|
|
@ -1295,6 +1315,20 @@ public static partial class Gen5SpirvTranslator
|
|||
IReadOnlyList<ShaderBlock> blocks,
|
||||
int blockIndex,
|
||||
out string error)
|
||||
{
|
||||
if (!TryEmitBlockBody(blocks, blockIndex, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryEmitBlockTerminator(blocks, blockIndex, out error);
|
||||
}
|
||||
|
||||
// Every instruction of a block except its terminating branch or s_endpgm.
|
||||
private bool TryEmitBlockBody(
|
||||
IReadOnlyList<ShaderBlock> blocks,
|
||||
int blockIndex,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
var block = blocks[blockIndex];
|
||||
|
|
@ -1337,6 +1371,17 @@ public static partial class Gen5SpirvTranslator
|
|||
}
|
||||
|
||||
if (synchronizeSharedMemory && sharedMemoryPhase != SharedMemoryPhase.None) EmitWave64Barrier();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stores the next dispatcher block (or ends the program) for a block's terminator.
|
||||
private bool TryEmitBlockTerminator(
|
||||
IReadOnlyList<ShaderBlock> blocks,
|
||||
int blockIndex,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
var block = blocks[blockIndex];
|
||||
var terminator = _request.Program.Instructions[block.EndIndex - 1];
|
||||
if (terminator.Opcode == "SEndpgm")
|
||||
{
|
||||
|
|
@ -1414,6 +1459,315 @@ public static partial class Gen5SpirvTranslator
|
|||
return true;
|
||||
}
|
||||
|
||||
// Forward-only, properly nested control flow is emitted as structured selections instead of
|
||||
// the PC-dispatch loop: a loop around a switch keeps every register live across iterations,
|
||||
// blocks cannot be optimized together, and the Metal compiler spills. SHARPEMU_STRUCTURED_CF=0
|
||||
// keeps the dispatcher for every program.
|
||||
private static readonly bool StructuredControlFlow =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_STRUCTURED_CF") != "0";
|
||||
|
||||
private static bool IsStructuredCondition(string opcode) => opcode is
|
||||
"SCbranchScc0" or "SCbranchScc1" or "SCbranchVccz" or "SCbranchVccnz" or
|
||||
"SCbranchExecz" or "SCbranchExecnz" or
|
||||
"SCbranchCdbgsys" or "SCbranchCdbguser" or "SCbranchCdbgsysOrUser" or "SCbranchCdbgsysAndUser";
|
||||
|
||||
// The block a branch continues at, or blocks.Count for a branch past the last instruction.
|
||||
private bool TryResolveBranchBlock(IReadOnlyList<ShaderBlock> blocks, Gen5ShaderInstruction branch, out int target)
|
||||
{
|
||||
target = -1;
|
||||
if (!TryGetBranchTargetPc(branch, out var targetPc))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsExitBranchTarget(_request.Program.Instructions, targetPc))
|
||||
{
|
||||
target = blocks.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryFindBlock(blocks, targetPc, out target);
|
||||
}
|
||||
|
||||
// Emits (or, without emit, only checks) blocks [begin, end). A branch from the range's last
|
||||
// block to leave falls through to the range end. Inside a loop body, latch is the block
|
||||
// whose backward branch the caller emits as the loop's continue, and a branch to end would
|
||||
// leave the loop, which a structured body cannot express.
|
||||
private bool TryStructureRange(
|
||||
IReadOnlyList<ShaderBlock> blocks,
|
||||
int begin,
|
||||
int end,
|
||||
int leave,
|
||||
bool emit,
|
||||
out string error,
|
||||
int latch = -1)
|
||||
{
|
||||
error = string.Empty;
|
||||
var instructions = _request.Program.Instructions;
|
||||
var index = begin;
|
||||
while (index < end)
|
||||
{
|
||||
// A loop body starts at its own header, which must not open the loop again.
|
||||
if (!(latch >= 0 && index == begin) && index != latch &&
|
||||
TryFindLoopLatch(blocks, index, end, out var loopLatch))
|
||||
{
|
||||
if (!TryStructureLoop(blocks, index, loopLatch, emit, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
index = loopLatch + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (emit && !TryEmitBlockBody(blocks, index, out error))
|
||||
{
|
||||
error = $"block=0x{blocks[index].StartPc:X}: {error}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (index == latch)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var terminator = instructions[blocks[index].EndIndex - 1];
|
||||
var next = index + 1;
|
||||
if (terminator.Opcode == "SEndpgm")
|
||||
{
|
||||
if (next != blocks.Count)
|
||||
{
|
||||
error = "s_endpgm before the last block";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (emit)
|
||||
{
|
||||
Store(_programActive, _module.ConstantBool(false));
|
||||
}
|
||||
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (terminator.Opcode == "SBranch")
|
||||
{
|
||||
if (!TryResolveBranchBlock(blocks, terminator, out var branchTarget) ||
|
||||
!(branchTarget == next || (next == end && branchTarget == leave)))
|
||||
{
|
||||
error = $"unstructured s_branch at 0x{terminator.Pc:X}";
|
||||
return false;
|
||||
}
|
||||
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!terminator.Opcode.StartsWith("SCbranch", StringComparison.Ordinal))
|
||||
{
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsStructuredCondition(terminator.Opcode) ||
|
||||
!TryResolveBranchBlock(blocks, terminator, out var target) || target <= index)
|
||||
{
|
||||
error = $"unstructured {terminator.Opcode} at 0x{terminator.Pc:X}";
|
||||
return false;
|
||||
}
|
||||
|
||||
// if (taken) break; -- only out of the innermost loop, to the block after its latch.
|
||||
if (_structuredLoopExit >= 0 && target == _structuredLoopExit)
|
||||
{
|
||||
if (emit)
|
||||
{
|
||||
TryGetBranchCondition(terminator.Opcode, out var leaveLoop);
|
||||
var breakLabel = _module.AllocateId();
|
||||
var stayLabel = _module.AllocateId();
|
||||
_module.AddStatement(SpirvOp.SelectionMerge, stayLabel, 0);
|
||||
_module.AddStatement(SpirvOp.BranchConditional, leaveLoop, breakLabel, stayLabel);
|
||||
_module.AddLabel(breakLabel);
|
||||
_module.AddStatement(SpirvOp.Branch, _structuredLoopMerge);
|
||||
_module.AddLabel(stayLabel);
|
||||
}
|
||||
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target > end || (latch >= 0 && target == end))
|
||||
{
|
||||
error = $"unstructured {terminator.Opcode} at 0x{terminator.Pc:X}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target == next)
|
||||
{
|
||||
index = next;
|
||||
continue;
|
||||
}
|
||||
|
||||
// if/else: the skipped range ends with an s_branch over the taken range.
|
||||
var thenLast = instructions[blocks[target - 1].EndIndex - 1];
|
||||
if (target < blocks.Count && target - 1 > index && thenLast.Opcode == "SBranch" &&
|
||||
TryResolveBranchBlock(blocks, thenLast, out var joinTarget) && joinTarget > target && joinTarget <= end &&
|
||||
!(latch >= 0 && joinTarget == end))
|
||||
{
|
||||
if (!emit)
|
||||
{
|
||||
if (!TryStructureRange(blocks, next, target, joinTarget, emit: false, out error) ||
|
||||
!TryStructureRange(blocks, target, joinTarget, joinTarget, emit: false, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TryGetBranchCondition(terminator.Opcode, out var taken);
|
||||
var thenLabel = _module.AllocateId();
|
||||
var elseLabel = _module.AllocateId();
|
||||
var joinLabel = _module.AllocateId();
|
||||
_module.AddStatement(SpirvOp.SelectionMerge, joinLabel, 0);
|
||||
_module.AddStatement(SpirvOp.BranchConditional, taken, elseLabel, thenLabel);
|
||||
_module.AddLabel(thenLabel);
|
||||
if (!TryStructureRange(blocks, next, target, joinTarget, emit: true, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.Branch, joinLabel);
|
||||
_module.AddLabel(elseLabel);
|
||||
if (!TryStructureRange(blocks, target, joinTarget, joinTarget, emit: true, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.Branch, joinLabel);
|
||||
_module.AddLabel(joinLabel);
|
||||
}
|
||||
|
||||
index = joinTarget;
|
||||
continue;
|
||||
}
|
||||
|
||||
// if (!taken) { skipped range }
|
||||
if (!emit)
|
||||
{
|
||||
if (!TryStructureRange(blocks, next, target, target, emit: false, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TryGetBranchCondition(terminator.Opcode, out var taken);
|
||||
var bodyLabel = _module.AllocateId();
|
||||
var mergeLabel = _module.AllocateId();
|
||||
_module.AddStatement(SpirvOp.SelectionMerge, mergeLabel, 0);
|
||||
_module.AddStatement(SpirvOp.BranchConditional, taken, mergeLabel, bodyLabel);
|
||||
_module.AddLabel(bodyLabel);
|
||||
if (!TryStructureRange(blocks, next, target, target, emit: true, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.Branch, mergeLabel);
|
||||
_module.AddLabel(mergeLabel);
|
||||
}
|
||||
|
||||
index = target;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// The innermost structured loop's exit block and merge label, for its breaks; -1 outside loops.
|
||||
private int _structuredLoopExit = -1;
|
||||
private uint _structuredLoopMerge;
|
||||
|
||||
// The last block in [header, end) that branches back to header, unconditionally or on a
|
||||
// wave-uniform condition.
|
||||
private bool TryFindLoopLatch(IReadOnlyList<ShaderBlock> blocks, int header, int end, out int latch)
|
||||
{
|
||||
latch = -1;
|
||||
var instructions = _request.Program.Instructions;
|
||||
for (var index = end - 1; index >= header; index--)
|
||||
{
|
||||
var terminator = instructions[blocks[index].EndIndex - 1];
|
||||
if ((IsStructuredCondition(terminator.Opcode) || terminator.Opcode == "SBranch") &&
|
||||
TryResolveBranchBlock(blocks, terminator, out var target) && target == header)
|
||||
{
|
||||
latch = index;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// do { blocks [header, latch] } while (latch condition), bounded by the same step guard as
|
||||
// the dispatcher so a guest loop that never ends cannot wedge the GPU queue.
|
||||
private bool TryStructureLoop(IReadOnlyList<ShaderBlock> blocks, int header, int latch, bool emit, out string error)
|
||||
{
|
||||
var outerExit = _structuredLoopExit;
|
||||
var outerMerge = _structuredLoopMerge;
|
||||
try
|
||||
{
|
||||
return TryStructureLoopCore(blocks, header, latch, emit, out error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_structuredLoopExit = outerExit;
|
||||
_structuredLoopMerge = outerMerge;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryStructureLoopCore(IReadOnlyList<ShaderBlock> blocks, int header, int latch, bool emit, out string error)
|
||||
{
|
||||
_structuredLoopExit = latch + 1;
|
||||
if (!emit)
|
||||
{
|
||||
_structuredLoopMerge = 0;
|
||||
return TryStructureRange(blocks, header, latch + 1, latch + 1, emit: false, out error, latch);
|
||||
}
|
||||
|
||||
var headerLabel = _module.AllocateId();
|
||||
var bodyLabel = _module.AllocateId();
|
||||
var continueLabel = _module.AllocateId();
|
||||
var mergeLabel = _module.AllocateId();
|
||||
_structuredLoopMerge = mergeLabel;
|
||||
_module.AddStatement(SpirvOp.Branch, headerLabel);
|
||||
_module.AddLabel(headerLabel);
|
||||
_module.AddStatement(SpirvOp.LoopMerge, mergeLabel, continueLabel, 0);
|
||||
_module.AddStatement(SpirvOp.Branch, bodyLabel);
|
||||
_module.AddLabel(bodyLabel);
|
||||
if (!TryStructureRange(blocks, header, latch + 1, latch + 1, emit: true, out error, latch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.Branch, continueLabel);
|
||||
_module.AddLabel(continueLabel);
|
||||
var terminator = _request.Program.Instructions[blocks[latch].EndIndex - 1];
|
||||
var again = _module.ConstantBool(true);
|
||||
if (terminator.Opcode != "SBranch")
|
||||
{
|
||||
TryGetBranchCondition(terminator.Opcode, out again);
|
||||
}
|
||||
|
||||
if (_maxDispatcherSteps > 0)
|
||||
{
|
||||
var steps = IAdd(Load(_uintType, _iterationGuard), UInt(1));
|
||||
Store(_iterationGuard, steps);
|
||||
var withinLimit = _module.AddInstruction(SpirvOp.ULessThan, _boolType, steps, UInt((uint)_maxDispatcherSteps));
|
||||
again = _module.AddInstruction(SpirvOp.LogicalAnd, _boolType, again, withinLimit);
|
||||
}
|
||||
|
||||
_module.AddStatement(SpirvOp.BranchConditional, again, headerLabel, mergeLabel);
|
||||
_module.AddLabel(mergeLabel);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatBlockStarts(IReadOnlyList<ShaderBlock> blocks)
|
||||
{
|
||||
const int maxBlocks = 32;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using Xunit;
|
||||
using static SharpEmu.ShaderCompiler.Tests.Resources.ResourceTestProgram;
|
||||
|
||||
namespace SharpEmu.ShaderCompiler.Tests;
|
||||
|
||||
// A loop closed by a backward branch is emitted as a structured SPIR-V loop instead of the PC
|
||||
// dispatcher, with conditional exits from its body as breaks. Any other backward edge keeps the
|
||||
// dispatcher.
|
||||
public sealed class Gen5StructuredLoopTests
|
||||
{
|
||||
private const uint MoveFour = 0xBE800384; // s_mov_b32 s0, 4
|
||||
private const uint SubtractOne = 0x80808100; // s_sub_u32 s0, s0, 1
|
||||
private const uint CompareNotZero = 0xBF078000; // s_cmp_lg_u32 s0, 0
|
||||
private const uint CompareTwo = 0xBF068200; // s_cmp_eq_u32 s0, 2
|
||||
private const uint CompareZero = 0xBF068000; // s_cmp_eq_u32 s0, 0
|
||||
private const uint EndProgram = 0xBF810000; // s_endpgm
|
||||
|
||||
[Fact]
|
||||
public void BackwardConditionalBranchBecomesAStructuredLoop()
|
||||
{
|
||||
var spirv = Compile(MoveFour, SubtractOne, CompareNotZero, 0xBF85FFFD /* s_cbranch_scc1 0x04 */, EndProgram);
|
||||
|
||||
var opcodes = Opcodes(spirv);
|
||||
Assert.Contains(SpirvOp.LoopMerge, opcodes);
|
||||
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
|
||||
ValidateWhenAvailable(spirv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConditionalExitFromTheBodyBecomesABreak()
|
||||
{
|
||||
var spirv = Compile(
|
||||
MoveFour, SubtractOne, CompareTwo,
|
||||
0xBF850002, // s_cbranch_scc1 0x18, past the loop
|
||||
CompareNotZero,
|
||||
0xBF85FFFB, // s_cbranch_scc1 0x04
|
||||
EndProgram);
|
||||
|
||||
var opcodes = Opcodes(spirv);
|
||||
Assert.Contains(SpirvOp.LoopMerge, opcodes);
|
||||
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
|
||||
ValidateWhenAvailable(spirv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnconditionalBackBranchLoopLeftByABreakIsStructured()
|
||||
{
|
||||
var spirv = Compile(
|
||||
MoveFour, SubtractOne, CompareZero,
|
||||
0xBF850001, // s_cbranch_scc1 0x14, past the loop
|
||||
0xBF82FFFC, // s_branch 0x04
|
||||
EndProgram);
|
||||
|
||||
var opcodes = Opcodes(spirv);
|
||||
Assert.Contains(SpirvOp.LoopMerge, opcodes);
|
||||
Assert.DoesNotContain(SpirvOp.Switch, opcodes);
|
||||
ValidateWhenAvailable(spirv);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecondBackEdgeToTheHeaderKeepsTheDispatcher()
|
||||
{
|
||||
var spirv = Compile(
|
||||
MoveFour, SubtractOne, CompareTwo,
|
||||
0xBF85FFFD, // s_cbranch_scc1 0x04, a continue from the middle of the body
|
||||
CompareNotZero,
|
||||
0xBF85FFFB, // s_cbranch_scc1 0x04
|
||||
EndProgram);
|
||||
|
||||
Assert.Contains(SpirvOp.Switch, Opcodes(spirv));
|
||||
ValidateWhenAvailable(spirv);
|
||||
}
|
||||
|
||||
private static byte[] Compile(params uint[] words)
|
||||
{
|
||||
var context = new CpuContext(new InstructionMemory(words), Generation.Gen5);
|
||||
Assert.True(Gen5ShaderTranslator.TryDecodeProgram(context, 0x1000, out var program, out var decodeError), decodeError);
|
||||
var (plan, resources, layout) = Prepare(program);
|
||||
var request = new ShaderCompileRequest(plan, resources, layout) { LocalSizeX = 1, ThreadCountX = 1 };
|
||||
Assert.True(Gen5SpirvTranslator.TryCompileProgram(request, out var shader, out var error), error);
|
||||
return shader.Spirv;
|
||||
}
|
||||
|
||||
private static List<SpirvOp> Opcodes(byte[] code)
|
||||
{
|
||||
var result = new List<SpirvOp>();
|
||||
for (var index = 5 * sizeof(uint); index < code.Length;)
|
||||
{
|
||||
var word = BinaryPrimitives.ReadUInt32LittleEndian(code.AsSpan(index));
|
||||
result.Add((SpirvOp)(word & 0xFFFF));
|
||||
index += (int)(word >> 16) * sizeof(uint);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateWhenAvailable(byte[] code)
|
||||
{
|
||||
var sdk = Environment.GetEnvironmentVariable("VULKAN_SDK");
|
||||
var executable = string.IsNullOrWhiteSpace(sdk) ? null
|
||||
: Path.Combine(sdk, OperatingSystem.IsWindows() ? "Bin/spirv-val.exe" : "bin/spirv-val");
|
||||
if (executable is null || !File.Exists(executable))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var path = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(path, code);
|
||||
var start = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardError = true, CreateNoWindow = true };
|
||||
start.ArgumentList.Add("--target-env");
|
||||
start.ArgumentList.Add("vulkan1.2");
|
||||
start.ArgumentList.Add(path);
|
||||
using var process = Process.Start(start)!;
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit();
|
||||
Assert.True(process.ExitCode == 0, error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InstructionMemory(uint[] words) : ICpuMemory
|
||||
{
|
||||
public bool TryRead(ulong address, Span<byte> destination)
|
||||
{
|
||||
var index = (address - 0x1000) / sizeof(uint);
|
||||
if (address < 0x1000 || (address - 0x1000) % sizeof(uint) != 0 || destination.Length != sizeof(uint) ||
|
||||
index >= (ulong)words.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination, words[(int)index]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryWrite(ulong address, ReadOnlySpan<byte> source) => false;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue