mirror of
https://github.com/Thealexbarney/LibHac.git
synced 2024-11-14 10:49:41 +01:00
Use IStorage for the bucket tree builder instead of Spans
This commit is contained in:
parent
9589f681a6
commit
4b4b354a7e
2 changed files with 103 additions and 80 deletions
|
@ -257,7 +257,7 @@ namespace LibHac.FsSystem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NodeBuffer
|
private struct NodeBuffer
|
||||||
{
|
{
|
||||||
// Use long to ensure alignment
|
// Use long to ensure alignment
|
||||||
private long[] _header;
|
private long[] _header;
|
||||||
|
|
|
@ -9,10 +9,14 @@ namespace LibHac.FsSystem
|
||||||
{
|
{
|
||||||
public partial class BucketTree2
|
public partial class BucketTree2
|
||||||
{
|
{
|
||||||
public ref struct Builder
|
public class Builder
|
||||||
{
|
{
|
||||||
private Span<byte> NodeBuffer { get; set; }
|
private SubStorage2 NodeStorage { get; set; }
|
||||||
private Span<byte> EntryBuffer { get; set; }
|
private SubStorage2 EntryStorage { get; set; }
|
||||||
|
|
||||||
|
private NodeBuffer _l1Node = new NodeBuffer();
|
||||||
|
private NodeBuffer _l2Node = new NodeBuffer();
|
||||||
|
private NodeBuffer _entrySet = new NodeBuffer();
|
||||||
|
|
||||||
private int NodeSize { get; set; }
|
private int NodeSize { get; set; }
|
||||||
private int EntrySize { get; set; }
|
private int EntrySize { get; set; }
|
||||||
|
@ -22,19 +26,19 @@ namespace LibHac.FsSystem
|
||||||
|
|
||||||
private int CurrentL2OffsetIndex { get; set; }
|
private int CurrentL2OffsetIndex { get; set; }
|
||||||
private int CurrentEntryIndex { get; set; }
|
private int CurrentEntryIndex { get; set; }
|
||||||
private long CurrentOffset { get; set; }
|
private long CurrentOffset { get; set; } = -1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the bucket tree builder.
|
/// Initializes the bucket tree builder.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="headerBuffer">The buffer for the tree's header. Must be at least the size in bytes returned by <see cref="QueryHeaderStorageSize"/>.</param>
|
/// <param name="headerStorage">The <see cref="SubStorage2"/> the tree's header will be written to.Must be at least the size in bytes returned by <see cref="QueryHeaderStorageSize"/>.</param>
|
||||||
/// <param name="nodeBuffer">The buffer for the tree's nodes. Must be at least the size in bytes returned by <see cref="QueryNodeStorageSize"/>.</param>
|
/// <param name="nodeStorage">The <see cref="SubStorage2"/> the tree's nodes will be written to. Must be at least the size in bytes returned by <see cref="QueryNodeStorageSize"/>.</param>
|
||||||
/// <param name="entryBuffer">The buffer for the tree's entries. Must be at least the size in bytes returned by <see cref="QueryEntryStorageSize"/>.</param>
|
/// <param name="entryStorage">The <see cref="SubStorage2"/> the tree's entries will be written to. Must be at least the size in bytes returned by <see cref="QueryEntryStorageSize"/>.</param>
|
||||||
/// <param name="nodeSize">The size of each node in the bucket tree.</param>
|
/// <param name="nodeSize">The size of each node in the bucket tree. Must be a power of 2.</param>
|
||||||
/// <param name="entrySize">The size of each entry that will be stored in the bucket tree.</param>
|
/// <param name="entrySize">The size of each entry that will be stored in the bucket tree.</param>
|
||||||
/// <param name="entryCount">The exact number of entries that will be added to the bucket tree.</param>
|
/// <param name="entryCount">The exact number of entries that will be added to the bucket tree.</param>
|
||||||
/// <returns>The <see cref="Result"/> of the operation.</returns>
|
/// <returns>The <see cref="Result"/> of the operation.</returns>
|
||||||
public Result Initialize(Span<byte> headerBuffer, Span<byte> nodeBuffer, Span<byte> entryBuffer,
|
public Result Initialize(SubStorage2 headerStorage, SubStorage2 nodeStorage, SubStorage2 entryStorage,
|
||||||
int nodeSize, int entrySize, int entryCount)
|
int nodeSize, int entrySize, int entryCount)
|
||||||
{
|
{
|
||||||
Assert.AssertTrue(entrySize >= sizeof(long));
|
Assert.AssertTrue(entrySize >= sizeof(long));
|
||||||
|
@ -42,6 +46,10 @@ namespace LibHac.FsSystem
|
||||||
Assert.AssertTrue(NodeSizeMin <= nodeSize && nodeSize <= NodeSizeMax);
|
Assert.AssertTrue(NodeSizeMin <= nodeSize && nodeSize <= NodeSizeMax);
|
||||||
Assert.AssertTrue(Util.IsPowerOfTwo(nodeSize));
|
Assert.AssertTrue(Util.IsPowerOfTwo(nodeSize));
|
||||||
|
|
||||||
|
if (headerStorage is null || nodeStorage is null || entryStorage is null)
|
||||||
|
return ResultFs.NullptrArgument.Log();
|
||||||
|
|
||||||
|
// Set the builder parameters
|
||||||
NodeSize = nodeSize;
|
NodeSize = nodeSize;
|
||||||
EntrySize = entrySize;
|
EntrySize = entrySize;
|
||||||
EntryCount = entryCount;
|
EntryCount = entryCount;
|
||||||
|
@ -50,27 +58,30 @@ namespace LibHac.FsSystem
|
||||||
OffsetsPerNode = GetOffsetCount(nodeSize);
|
OffsetsPerNode = GetOffsetCount(nodeSize);
|
||||||
CurrentL2OffsetIndex = GetNodeL2Count(nodeSize, entrySize, entryCount);
|
CurrentL2OffsetIndex = GetNodeL2Count(nodeSize, entrySize, entryCount);
|
||||||
|
|
||||||
// Verify the provided buffers are large enough
|
// Create and write the header
|
||||||
int nodeStorageSize = (int)QueryNodeStorageSize(nodeSize, entrySize, entryCount);
|
var header = new Header();
|
||||||
int entryStorageSize = (int)QueryEntryStorageSize(nodeSize, entrySize, entryCount);
|
header.Format(entryCount);
|
||||||
|
Result rc = headerStorage.Write(0, SpanHelpers.AsByteSpan(ref header));
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
if (headerBuffer.Length < QueryHeaderStorageSize() ||
|
// Allocate buffers for the L1 node and entry sets
|
||||||
nodeBuffer.Length < nodeStorageSize ||
|
_l1Node.Allocate(nodeSize);
|
||||||
entryBuffer.Length < entryStorageSize)
|
_entrySet.Allocate(nodeSize);
|
||||||
|
|
||||||
|
int entrySetCount = GetEntrySetCount(nodeSize, entrySize, entryCount);
|
||||||
|
|
||||||
|
// Allocate an L2 node buffer if there are more entry sets than will fit in the L1 node
|
||||||
|
if (OffsetsPerNode < entrySetCount)
|
||||||
{
|
{
|
||||||
return ResultFs.InvalidSize.Log();
|
_l2Node.Allocate(nodeSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set and clear the buffers
|
_l1Node.FillZero();
|
||||||
NodeBuffer = nodeBuffer.Slice(0, nodeStorageSize);
|
_l2Node.FillZero();
|
||||||
EntryBuffer = entryBuffer.Slice(0, entryStorageSize);
|
_entrySet.FillZero();
|
||||||
|
|
||||||
nodeBuffer.Clear();
|
NodeStorage = nodeStorage;
|
||||||
entryBuffer.Clear();
|
EntryStorage = entryStorage;
|
||||||
|
|
||||||
// Format the tree header
|
|
||||||
ref Header header = ref SpanHelpers.AsStruct<Header>(headerBuffer);
|
|
||||||
header.Format(entryCount);
|
|
||||||
|
|
||||||
// Set the initial position
|
// Set the initial position
|
||||||
CurrentEntryIndex = 0;
|
CurrentEntryIndex = 0;
|
||||||
|
@ -92,18 +103,20 @@ namespace LibHac.FsSystem
|
||||||
if (CurrentEntryIndex >= EntryCount)
|
if (CurrentEntryIndex >= EntryCount)
|
||||||
return ResultFs.OutOfRange.Log();
|
return ResultFs.OutOfRange.Log();
|
||||||
|
|
||||||
|
// The entry offset must always be the first 8 bytes of the struct
|
||||||
long entryOffset = BinaryPrimitives.ReadInt64LittleEndian(SpanHelpers.AsByteSpan(ref entry));
|
long entryOffset = BinaryPrimitives.ReadInt64LittleEndian(SpanHelpers.AsByteSpan(ref entry));
|
||||||
|
|
||||||
if (entryOffset <= CurrentOffset)
|
if (entryOffset <= CurrentOffset)
|
||||||
return ResultFs.InvalidOffset.Log();
|
return ResultFs.InvalidOffset.Log();
|
||||||
|
|
||||||
FinalizePreviousEntrySet(entryOffset);
|
Result rc = FinalizePreviousEntrySet(entryOffset);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
AddEntryOffset(entryOffset);
|
AddEntryOffset(entryOffset);
|
||||||
|
|
||||||
int entrySetIndex = CurrentEntryIndex / EntriesPerEntrySet;
|
// Write the new entry
|
||||||
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
||||||
|
_entrySet.GetNode<T>().GetWritableArray()[indexInEntrySet] = entry;
|
||||||
GetEntrySet<T>(entrySetIndex).GetWritableArray()[indexInEntrySet] = entry;
|
|
||||||
|
|
||||||
CurrentOffset = entryOffset;
|
CurrentOffset = entryOffset;
|
||||||
CurrentEntryIndex++;
|
CurrentEntryIndex++;
|
||||||
|
@ -112,10 +125,12 @@ namespace LibHac.FsSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a new entry set is being started. If so, sets the end offset of the previous entry set.
|
/// Checks if a new entry set is being started. If so, sets the end offset of the previous
|
||||||
|
/// entry set and writes it to the output storage.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="endOffset">The end offset of the previous entry.</param>
|
/// <param name="endOffset">The end offset of the previous entry.</param>
|
||||||
private void FinalizePreviousEntrySet(long endOffset)
|
/// <returns>The <see cref="Result"/> of the operation.</returns>
|
||||||
|
private Result FinalizePreviousEntrySet(long endOffset)
|
||||||
{
|
{
|
||||||
int prevEntrySetIndex = CurrentEntryIndex / EntriesPerEntrySet - 1;
|
int prevEntrySetIndex = CurrentEntryIndex / EntriesPerEntrySet - 1;
|
||||||
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
||||||
|
@ -124,11 +139,19 @@ namespace LibHac.FsSystem
|
||||||
if (CurrentEntryIndex > 0 && indexInEntrySet == 0)
|
if (CurrentEntryIndex > 0 && indexInEntrySet == 0)
|
||||||
{
|
{
|
||||||
// Set the end offset of that entry set
|
// Set the end offset of that entry set
|
||||||
BucketTreeNode<long> prevEntrySet = GetEntrySet<long>(prevEntrySetIndex);
|
ref NodeHeader entrySetHeader = ref _entrySet.GetHeader();
|
||||||
|
|
||||||
prevEntrySet.GetHeader().Index = prevEntrySetIndex;
|
entrySetHeader.Index = prevEntrySetIndex;
|
||||||
prevEntrySet.GetHeader().Count = EntriesPerEntrySet;
|
entrySetHeader.Count = EntriesPerEntrySet;
|
||||||
prevEntrySet.GetHeader().Offset = endOffset;
|
entrySetHeader.Offset = endOffset;
|
||||||
|
|
||||||
|
// Write the entry set to the entry storage
|
||||||
|
long storageOffset = (long)NodeSize * prevEntrySetIndex;
|
||||||
|
Result rc = EntryStorage.Write(storageOffset, _entrySet.GetBuffer());
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
// Clear the entry set buffer to begin the new entry set
|
||||||
|
_entrySet.FillZero();
|
||||||
|
|
||||||
// Check if we're writing in L2 nodes
|
// Check if we're writing in L2 nodes
|
||||||
if (CurrentL2OffsetIndex > OffsetsPerNode)
|
if (CurrentL2OffsetIndex > OffsetsPerNode)
|
||||||
|
@ -140,14 +163,24 @@ namespace LibHac.FsSystem
|
||||||
if (indexInL2Node == 0)
|
if (indexInL2Node == 0)
|
||||||
{
|
{
|
||||||
// Set the end offset of that node
|
// Set the end offset of that node
|
||||||
BucketTreeNode<long> prevL2Node = GetL2Node(prevL2NodeIndex);
|
ref NodeHeader l2NodeHeader = ref _l2Node.GetHeader();
|
||||||
|
|
||||||
prevL2Node.GetHeader().Index = prevL2NodeIndex;
|
l2NodeHeader.Index = prevL2NodeIndex;
|
||||||
prevL2Node.GetHeader().Count = OffsetsPerNode;
|
l2NodeHeader.Count = OffsetsPerNode;
|
||||||
prevL2Node.GetHeader().Offset = endOffset;
|
l2NodeHeader.Offset = endOffset;
|
||||||
|
|
||||||
|
// Write the L2 node to the node storage
|
||||||
|
long nodeOffset = (long)NodeSize * (prevL2NodeIndex + 1);
|
||||||
|
rc = NodeStorage.Write(nodeOffset, _l2Node.GetBuffer());
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
// Clear the L2 node buffer to begin the new node
|
||||||
|
_l2Node.FillZero();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -162,19 +195,19 @@ namespace LibHac.FsSystem
|
||||||
// If we're starting a new entry set we need to add its start offset to the L1/L2 nodes
|
// If we're starting a new entry set we need to add its start offset to the L1/L2 nodes
|
||||||
if (indexInEntrySet == 0)
|
if (indexInEntrySet == 0)
|
||||||
{
|
{
|
||||||
BucketTreeNode<long> l1Node = GetL1Node();
|
Span<long> l1Data = _l1Node.GetNode<long>().GetWritableArray();
|
||||||
|
|
||||||
if (CurrentL2OffsetIndex == 0)
|
if (CurrentL2OffsetIndex == 0)
|
||||||
{
|
{
|
||||||
// There are no L2 nodes. Write the entry set end offset directly to L1
|
// There are no L2 nodes. Write the entry set end offset directly to L1
|
||||||
l1Node.GetWritableArray()[entrySetIndex] = entryOffset;
|
l1Data[entrySetIndex] = entryOffset;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (CurrentL2OffsetIndex < OffsetsPerNode)
|
if (CurrentL2OffsetIndex < OffsetsPerNode)
|
||||||
{
|
{
|
||||||
// The current L2 offset is stored in the L1 node
|
// The current L2 offset is stored in the L1 node
|
||||||
l1Node.GetWritableArray()[CurrentL2OffsetIndex] = entryOffset;
|
l1Data[CurrentL2OffsetIndex] = entryOffset;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -182,14 +215,13 @@ namespace LibHac.FsSystem
|
||||||
int l2NodeIndex = CurrentL2OffsetIndex / OffsetsPerNode;
|
int l2NodeIndex = CurrentL2OffsetIndex / OffsetsPerNode;
|
||||||
int indexInL2Node = CurrentL2OffsetIndex % OffsetsPerNode;
|
int indexInL2Node = CurrentL2OffsetIndex % OffsetsPerNode;
|
||||||
|
|
||||||
BucketTreeNode<long> l2Node = GetL2Node(l2NodeIndex - 1);
|
Span<long> l2Data = _l2Node.GetNode<long>().GetWritableArray();
|
||||||
|
l2Data[indexInL2Node] = entryOffset;
|
||||||
l2Node.GetWritableArray()[indexInL2Node] = entryOffset;
|
|
||||||
|
|
||||||
// If we're starting a new L2 node we need to add its start offset to the L1 node
|
// If we're starting a new L2 node we need to add its start offset to the L1 node
|
||||||
if (indexInL2Node == 0)
|
if (indexInL2Node == 0)
|
||||||
{
|
{
|
||||||
l1Node.GetWritableArray()[l2NodeIndex - 1] = entryOffset;
|
l1Data[l2NodeIndex - 1] = entryOffset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,7 +244,11 @@ namespace LibHac.FsSystem
|
||||||
if (endOffset <= CurrentOffset)
|
if (endOffset <= CurrentOffset)
|
||||||
return ResultFs.InvalidOffset.Log();
|
return ResultFs.InvalidOffset.Log();
|
||||||
|
|
||||||
FinalizePreviousEntrySet(endOffset);
|
if (CurrentOffset == -1)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
Result rc = FinalizePreviousEntrySet(endOffset);
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
int entrySetIndex = CurrentEntryIndex / EntriesPerEntrySet;
|
int entrySetIndex = CurrentEntryIndex / EntriesPerEntrySet;
|
||||||
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
int indexInEntrySet = CurrentEntryIndex % EntriesPerEntrySet;
|
||||||
|
@ -220,11 +256,15 @@ namespace LibHac.FsSystem
|
||||||
// Finalize the current entry set if needed
|
// Finalize the current entry set if needed
|
||||||
if (indexInEntrySet != 0)
|
if (indexInEntrySet != 0)
|
||||||
{
|
{
|
||||||
ref NodeHeader entrySetHeader = ref GetEntrySetHeader(entrySetIndex);
|
ref NodeHeader entrySetHeader = ref _entrySet.GetHeader();
|
||||||
|
|
||||||
entrySetHeader.Index = entrySetIndex;
|
entrySetHeader.Index = entrySetIndex;
|
||||||
entrySetHeader.Count = indexInEntrySet;
|
entrySetHeader.Count = indexInEntrySet;
|
||||||
entrySetHeader.Offset = endOffset;
|
entrySetHeader.Offset = endOffset;
|
||||||
|
|
||||||
|
long entryStorageOffset = (long)NodeSize * entrySetIndex;
|
||||||
|
rc = EntryStorage.Write(entryStorageOffset, _entrySet.GetBuffer());
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
int l2NodeIndex = Util.DivideByRoundUp(CurrentL2OffsetIndex, OffsetsPerNode) - 2;
|
int l2NodeIndex = Util.DivideByRoundUp(CurrentL2OffsetIndex, OffsetsPerNode) - 2;
|
||||||
|
@ -233,54 +273,37 @@ namespace LibHac.FsSystem
|
||||||
// Finalize the current L2 node if needed
|
// Finalize the current L2 node if needed
|
||||||
if (CurrentL2OffsetIndex > OffsetsPerNode && (indexInEntrySet != 0 || indexInL2Node != 0))
|
if (CurrentL2OffsetIndex > OffsetsPerNode && (indexInEntrySet != 0 || indexInL2Node != 0))
|
||||||
{
|
{
|
||||||
ref NodeHeader l2NodeHeader = ref GetL2Node(l2NodeIndex).GetHeader();
|
ref NodeHeader l2NodeHeader = ref _l2Node.GetHeader();
|
||||||
l2NodeHeader.Index = l2NodeIndex;
|
l2NodeHeader.Index = l2NodeIndex;
|
||||||
l2NodeHeader.Count = indexInL2Node != 0 ? indexInL2Node : OffsetsPerNode;
|
l2NodeHeader.Count = indexInL2Node != 0 ? indexInL2Node : OffsetsPerNode;
|
||||||
l2NodeHeader.Offset = endOffset;
|
l2NodeHeader.Offset = endOffset;
|
||||||
|
|
||||||
|
long l2NodeStorageOffset = NodeSize * (l2NodeIndex + 1);
|
||||||
|
rc = NodeStorage.Write(l2NodeStorageOffset, _l2Node.GetBuffer());
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize the L1 node
|
// Finalize the L1 node
|
||||||
ref NodeHeader l1Header = ref GetL1Node().GetHeader();
|
ref NodeHeader l1NodeHeader = ref _l1Node.GetHeader();
|
||||||
|
l1NodeHeader.Index = 0;
|
||||||
l1Header.Index = 0;
|
l1NodeHeader.Offset = endOffset;
|
||||||
l1Header.Offset = endOffset;
|
|
||||||
|
|
||||||
// L1 count depends on the existence or absence of L2 nodes
|
// L1 count depends on the existence or absence of L2 nodes
|
||||||
if (CurrentL2OffsetIndex == 0)
|
if (CurrentL2OffsetIndex == 0)
|
||||||
{
|
{
|
||||||
l1Header.Count = Util.DivideByRoundUp(CurrentEntryIndex, EntriesPerEntrySet);
|
l1NodeHeader.Count = Util.DivideByRoundUp(CurrentEntryIndex, EntriesPerEntrySet);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
l1Header.Count = l2NodeIndex + 1;
|
l1NodeHeader.Count = l2NodeIndex + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rc = NodeStorage.Write(0, _l1Node.GetBuffer());
|
||||||
|
if (rc.IsFailure()) return rc;
|
||||||
|
|
||||||
|
CurrentOffset = long.MaxValue;
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ref NodeHeader GetEntrySetHeader(int index)
|
|
||||||
{
|
|
||||||
BucketTreeNode<long> entrySetNode = GetEntrySet<long>(index);
|
|
||||||
return ref entrySetNode.GetHeader();
|
|
||||||
}
|
|
||||||
|
|
||||||
private BucketTreeNode<T> GetEntrySet<T>(int index) where T : unmanaged
|
|
||||||
{
|
|
||||||
Span<byte> entrySetBuffer = EntryBuffer.Slice(NodeSize * index, NodeSize);
|
|
||||||
return new BucketTreeNode<T>(entrySetBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BucketTreeNode<long> GetL1Node()
|
|
||||||
{
|
|
||||||
Span<byte> l1NodeBuffer = NodeBuffer.Slice(0, NodeSize);
|
|
||||||
return new BucketTreeNode<long>(l1NodeBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BucketTreeNode<long> GetL2Node(int index)
|
|
||||||
{
|
|
||||||
Span<byte> l2NodeBuffer = NodeBuffer.Slice(NodeSize * (index + 1), NodeSize);
|
|
||||||
return new BucketTreeNode<long>(l2NodeBuffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue