initial commit
This commit is contained in:
commit
1b60743303
274 changed files with 25866 additions and 0 deletions
55
SEGATools/GDEmu/DiscTrackCopyInfo.cs
Normal file
55
SEGATools/GDEmu/DiscTrackCopyInfo.cs
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Decompiled with JetBrains decompiler
|
||||
// Type: SEGATools.GDEmu.DiscTrackCopyInfo
|
||||
// Assembly: SEGATools, Version=1.0.3.0, Culture=neutral, PublicKeyToken=611be24fdeb07e08
|
||||
// MVID: D631183F-57B1-40A1-B502-5364D288307A
|
||||
// Assembly location: SEGATools.dll
|
||||
|
||||
using ImageReader.DiscSectors;
|
||||
using SEGATools.Binary;
|
||||
using SEGATools.DiscFileSystem;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SEGATools.GDEmu
|
||||
{
|
||||
internal class DiscTrackCopyInfo
|
||||
{
|
||||
private HashSet<BinaryPatch> patchesApplied;
|
||||
|
||||
public IDiscTrack SourceTrack { get; private set; }
|
||||
|
||||
public IDiscTrack DestinationTrack { get; private set; }
|
||||
|
||||
public int[] ModifiedSectors
|
||||
{
|
||||
get
|
||||
{
|
||||
HashSet<int> source = new HashSet<int>();
|
||||
foreach (BinaryPatch binaryPatch in this.patchesApplied)
|
||||
{
|
||||
int num1 = (int) binaryPatch.Offset / DiscSectorCommon.LogicalSectorSize;
|
||||
int num2 = (int) (binaryPatch.Offset + (long) binaryPatch.Data.Length) / DiscSectorCommon.LogicalSectorSize;
|
||||
for (int index = num1; index <= num2; ++index)
|
||||
source.Add(index);
|
||||
}
|
||||
return source.ToArray<int>();
|
||||
}
|
||||
}
|
||||
|
||||
public static DiscTrackCopyInfo CreateFrom(
|
||||
IDiscTrack source,
|
||||
string newFileName)
|
||||
{
|
||||
return new DiscTrackCopyInfo(source, DiscTrack.CreateCopyFrom(source, newFileName));
|
||||
}
|
||||
|
||||
private DiscTrackCopyInfo(IDiscTrack source, IDiscTrack destination)
|
||||
{
|
||||
this.SourceTrack = source;
|
||||
this.DestinationTrack = destination;
|
||||
this.patchesApplied = new HashSet<BinaryPatch>();
|
||||
}
|
||||
|
||||
public void AddPatches(params BinaryPatch[] patches) => this.patchesApplied.UnionWith((IEnumerable<BinaryPatch>) patches);
|
||||
}
|
||||
}
|
328
SEGATools/GDEmu/GDEmuConverter.cs
Normal file
328
SEGATools/GDEmu/GDEmuConverter.cs
Normal file
|
@ -0,0 +1,328 @@
|
|||
// Decompiled with JetBrains decompiler
|
||||
// Type: SEGATools.GDEmu.GDEmuConverter
|
||||
// Assembly: SEGATools, Version=1.0.3.0, Culture=neutral, PublicKeyToken=611be24fdeb07e08
|
||||
// MVID: D631183F-57B1-40A1-B502-5364D288307A
|
||||
// Assembly location: SEGATools.dll
|
||||
|
||||
using ImageReader.DiscSectors;
|
||||
using ImageReader.Stream;
|
||||
using SEGATools.Binary;
|
||||
using SEGATools.Disc;
|
||||
using SEGATools.DiscFileSystem;
|
||||
using SEGATools.UserProcess;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace SEGATools.GDEmu
|
||||
{
|
||||
public class GDEmuConverter : UserProcessBase
|
||||
{
|
||||
private static readonly int OutputFileStreamBuffer = 524288;
|
||||
private static readonly string FileConflictQuestionTitle = "GDEmuExporterFileConflictQuestionTitle";
|
||||
private static readonly string FileConflictQuestionContent = "GDEmuExporterFileConflictQuestionContent";
|
||||
private static readonly string BinaryPatcherHint = "GDEmuExporterBinaryPatcherHint";
|
||||
private static readonly string DiscSectorEncoderHint = "GDEmuExporterDiscSectorEncoderHint";
|
||||
private BinaryPatcher binaryPatcher = new BinaryPatcher();
|
||||
private DiscSectorEncoder discSectorEncoder = new DiscSectorEncoder();
|
||||
|
||||
public event AsyncOperationProgressChangedEventHandler ConversionProgressChanged
|
||||
{
|
||||
add => this.AsyncOperationProgressChanged += value;
|
||||
remove => this.AsyncOperationProgressChanged -= value;
|
||||
}
|
||||
|
||||
public event AsyncOperationCompletedEventHandler ConversionCompleted
|
||||
{
|
||||
add => this.AsyncOperationCompleted += value;
|
||||
remove => this.AsyncOperationCompleted -= value;
|
||||
}
|
||||
|
||||
public GDEmuConverter()
|
||||
{
|
||||
}
|
||||
|
||||
public GDEmuConverter(IContainer container)
|
||||
: base(container)
|
||||
{
|
||||
}
|
||||
|
||||
public void ConvertAsync(
|
||||
IDiscFileSystem GDIImageFile,
|
||||
GDEmuExportOptions ExportOptions,
|
||||
object taskId)
|
||||
{
|
||||
AsyncOperation asyncOperation = this.CreateAsyncOperation(taskId);
|
||||
new GDEmuConverter.FileConverterWorkerEventHandler(this.FileConverterWorker).BeginInvoke(GDIImageFile, ExportOptions, asyncOperation, (AsyncCallback) null, (object) null);
|
||||
}
|
||||
|
||||
private void FileConverterWorker(
|
||||
IDiscFileSystem GDIImageFile,
|
||||
GDEmuExportOptions ExportOption,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
Exception exception = (Exception) null;
|
||||
HashSet<DiscTrackCopyInfo> trackOutputFiles = this.GetTrackOutputFiles(GDIImageFile, ExportOption);
|
||||
List<string> stringList = new List<string>();
|
||||
this.CheckForFileConflict(ExportOption, trackOutputFiles, asyncOp);
|
||||
if (this.TaskCanceled(asyncOp))
|
||||
{
|
||||
this.DoExtractionCleanupIfNeeded(stringList.ToArray(), exception, asyncOp);
|
||||
this.ReportCompletion(ExportOption.OutputPath, exception, asyncOp);
|
||||
}
|
||||
else
|
||||
{
|
||||
long requiredSpaceInBytes = this.ComputeRequiredSpaceInBytes(trackOutputFiles);
|
||||
long ofBytesToExtract = this.ComputeNumberOfBytesToExtract(trackOutputFiles);
|
||||
long totalNumberOfBytesRemaining = ofBytesToExtract;
|
||||
try
|
||||
{
|
||||
this.CheckForEnoughFreeDiscSpace(requiredSpaceInBytes, ExportOption.OutputPath);
|
||||
GDICreator.CreateGDIFile(GDIImageFile.AllTracks, ExportOption.GetOutputGDIFilePath());
|
||||
stringList.Add(ExportOption.GetOutputGDIFilePath());
|
||||
foreach (DiscTrackCopyInfo track in trackOutputFiles)
|
||||
{
|
||||
if (!this.TaskCanceled(asyncOp))
|
||||
{
|
||||
stringList.Add(track.DestinationTrack.FileName);
|
||||
totalNumberOfBytesRemaining = this.CopyTrack(track, ofBytesToExtract, totalNumberOfBytesRemaining, asyncOp);
|
||||
}
|
||||
else
|
||||
break;
|
||||
}
|
||||
if (!this.TaskCanceled(asyncOp))
|
||||
this.ApplyExportPatches(GDIImageFile, ExportOption, trackOutputFiles, asyncOp);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exception = ex;
|
||||
UserProcessBase.logger.ErrorFormat("Unable to copy the track: {0}", (object) exception);
|
||||
}
|
||||
this.DoExtractionCleanupIfNeeded(stringList.ToArray(), exception, asyncOp);
|
||||
this.ReportCompletion(ExportOption.OutputPath, exception, asyncOp);
|
||||
}
|
||||
}
|
||||
|
||||
private long ComputeNumberOfBytesToExtract(HashSet<DiscTrackCopyInfo> tracks)
|
||||
{
|
||||
long num = 0;
|
||||
foreach (DiscTrackCopyInfo track in tracks)
|
||||
num += track.SourceTrack.Length;
|
||||
return num;
|
||||
}
|
||||
|
||||
private long ComputeRequiredSpaceInBytes(HashSet<DiscTrackCopyInfo> tracks)
|
||||
{
|
||||
long num = 0;
|
||||
foreach (DiscTrackCopyInfo track in tracks)
|
||||
{
|
||||
FileInfo fileInfo = new FileInfo(track.DestinationTrack.FileName);
|
||||
num += track.SourceTrack.Length;
|
||||
if (fileInfo.Exists)
|
||||
num -= fileInfo.Length;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
private void CheckForEnoughFreeDiscSpace(long requiredSpaceInBytes, string outputPath)
|
||||
{
|
||||
if (requiredSpaceInBytes > new DriveInfo(outputPath.Substring(0, 3)).AvailableFreeSpace)
|
||||
{
|
||||
UserProcessBase.logger.ErrorFormat("Extraction requires {0} bytes of free disc space in drive {1}", (object) requiredSpaceInBytes, (object) outputPath.Substring(0, 3));
|
||||
throw new IOException("Not enough free disc space!");
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckForFileConflict(
|
||||
GDEmuExportOptions exportOptions,
|
||||
HashSet<DiscTrackCopyInfo> inputFiles,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
List<string> stringList = new List<string>();
|
||||
if (File.Exists(exportOptions.GetOutputGDIFilePath()))
|
||||
stringList.Add(exportOptions.GetOutputGDIFilePath());
|
||||
foreach (DiscTrackCopyInfo inputFile in inputFiles)
|
||||
{
|
||||
if (File.Exists(inputFile.DestinationTrack.FileName))
|
||||
stringList.Add(inputFile.DestinationTrack.FileName);
|
||||
}
|
||||
if (stringList.Count == 0)
|
||||
return;
|
||||
this.AskForUserConsent(GDEmuConverter.GetNotifyFileConflictEventArgs(stringList.ToArray(), asyncOp), asyncOp);
|
||||
}
|
||||
|
||||
private long CopyTrack(
|
||||
DiscTrackCopyInfo track,
|
||||
long totalNumberOfBytesToExtract,
|
||||
long totalNumberOfBytesRemaining,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
using (System.IO.Stream fileInputStream = track.SourceTrack.FileInputStream)
|
||||
{
|
||||
using (System.IO.Stream fileOutputStream = track.DestinationTrack.FileOutputStream)
|
||||
{
|
||||
long length = fileInputStream.Length;
|
||||
byte[] buffer = new byte[GDEmuConverter.OutputFileStreamBuffer];
|
||||
while (length > 0L)
|
||||
{
|
||||
if (!this.TaskCanceled(asyncOp))
|
||||
{
|
||||
int count = fileInputStream.Read(buffer, 0, buffer.Length);
|
||||
length -= (long) count;
|
||||
totalNumberOfBytesRemaining -= (long) count;
|
||||
fileOutputStream.Write(buffer, 0, count);
|
||||
this.ReportProgress(GDEmuConverter.CreateProgressChangedEventArgs(track.SourceTrack.FileName, track.DestinationTrack.FileName, fileInputStream.Length, length, totalNumberOfBytesToExtract, totalNumberOfBytesRemaining, asyncOp), asyncOp);
|
||||
}
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalNumberOfBytesRemaining;
|
||||
}
|
||||
|
||||
private void ApplyExportPatches(
|
||||
IDiscFileSystem GDIImageFile,
|
||||
GDEmuExportOptions ExportOption,
|
||||
HashSet<DiscTrackCopyInfo> OutputTrackFiles,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
if (!ExportOption.ForceVGA && !ExportOption.RegionFree)
|
||||
{
|
||||
UserProcessBase.logger.Info((object) "No patch to apply");
|
||||
}
|
||||
else
|
||||
{
|
||||
this.UpdateViewForBinaryPatcherStep(asyncOp);
|
||||
if (ExportOption.ForceVGA)
|
||||
this.ApplyVGAPatch(OutputTrackFiles, asyncOp);
|
||||
if (ExportOption.RegionFree)
|
||||
this.ApplyRegionFreePatch(OutputTrackFiles, asyncOp);
|
||||
if (this.TaskCanceled(asyncOp))
|
||||
return;
|
||||
this.CorrectModifiedSectors(OutputTrackFiles, asyncOp);
|
||||
}
|
||||
}
|
||||
|
||||
private void CorrectModifiedSectors(HashSet<DiscTrackCopyInfo> tracks, AsyncOperation asyncOp)
|
||||
{
|
||||
this.UpdateViewForDiscSectorEncoderStep(asyncOp);
|
||||
foreach (DiscTrackCopyInfo track in tracks.Where<DiscTrackCopyInfo>((Func<DiscTrackCopyInfo, bool>) (track => track.SourceTrack.TrackData == TrackModeType.Data && track.SourceTrack.TrackSector.GetType() == typeof (CDROMMode1RawSector))))
|
||||
{
|
||||
this.ReportPatchOrErrorDataEncoderProgress(track, asyncOp);
|
||||
if (this.TaskCanceled(asyncOp))
|
||||
break;
|
||||
using (System.IO.Stream fileOutputStream = track.DestinationTrack.FileOutputStream)
|
||||
this.discSectorEncoder.EncodeMode1Sectors(fileOutputStream, track.ModifiedSectors);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyPatches(DiscTrackCopyInfo track, BinaryPatch[] patches)
|
||||
{
|
||||
using (DiscSectorStream discSectorStream = new DiscSectorStream(track.DestinationTrack.FileOutputStream, track.DestinationTrack.TrackSector))
|
||||
this.binaryPatcher.ApplyPatches((System.IO.Stream) discSectorStream, patches);
|
||||
track.AddPatches(patches);
|
||||
}
|
||||
|
||||
private void ApplyVGAPatch(HashSet<DiscTrackCopyInfo> OutputTrackFiles, AsyncOperation asyncOp)
|
||||
{
|
||||
DiscTrackCopyInfo track1 = OutputTrackFiles.First<DiscTrackCopyInfo>((Func<DiscTrackCopyInfo, bool>) (track => track.SourceTrack.Index == 1));
|
||||
UserProcessBase.logger.InfoFormat("Applying VGA flag patches on {0}", (object) track1);
|
||||
this.ReportPatchOrErrorDataEncoderProgress(track1, asyncOp);
|
||||
this.ApplyPatches(track1, InitialProgramPatches.VGAFlagPatchesForTrack1);
|
||||
DiscTrackCopyInfo track2 = OutputTrackFiles.First<DiscTrackCopyInfo>((Func<DiscTrackCopyInfo, bool>) (track => track.SourceTrack.Index == 3));
|
||||
UserProcessBase.logger.InfoFormat("Applying VGA flag patches on {0}", (object) track2);
|
||||
this.ReportPatchOrErrorDataEncoderProgress(track2, asyncOp);
|
||||
this.ApplyPatches(track2, InitialProgramPatches.VGAFlagPatchesForTrack3);
|
||||
}
|
||||
|
||||
private void ApplyRegionFreePatch(
|
||||
HashSet<DiscTrackCopyInfo> OutputTrackFiles,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
DiscTrackCopyInfo track1 = OutputTrackFiles.First<DiscTrackCopyInfo>((Func<DiscTrackCopyInfo, bool>) (track => track.SourceTrack.Index == 1));
|
||||
UserProcessBase.logger.InfoFormat("Applying region free patches on {0}", (object) track1);
|
||||
this.ReportPatchOrErrorDataEncoderProgress(track1, asyncOp);
|
||||
this.ApplyPatches(track1, InitialProgramPatches.RegionFreePatchesForTrack1);
|
||||
DiscTrackCopyInfo track2 = OutputTrackFiles.First<DiscTrackCopyInfo>((Func<DiscTrackCopyInfo, bool>) (track => track.SourceTrack.Index == 3));
|
||||
UserProcessBase.logger.InfoFormat("Applying region free patches on {0}", (object) track2);
|
||||
this.ReportPatchOrErrorDataEncoderProgress(track2, asyncOp);
|
||||
this.ApplyPatches(track2, InitialProgramPatches.RegionFreePatchesForTrack3);
|
||||
}
|
||||
|
||||
private HashSet<DiscTrackCopyInfo> GetTrackOutputFiles(
|
||||
IDiscFileSystem GDIImageFile,
|
||||
GDEmuExportOptions ExportOption)
|
||||
{
|
||||
HashSet<DiscTrackCopyInfo> discTrackCopyInfoSet = new HashSet<DiscTrackCopyInfo>();
|
||||
foreach (IDiscTrack allTrack in GDIImageFile.AllTracks)
|
||||
{
|
||||
string newFileName = Path.Combine(ExportOption.OutputPath, allTrack.VirtualName);
|
||||
discTrackCopyInfoSet.Add(DiscTrackCopyInfo.CreateFrom(allTrack, newFileName));
|
||||
}
|
||||
return discTrackCopyInfoSet;
|
||||
}
|
||||
|
||||
private void DoExtractionCleanupIfNeeded(
|
||||
string[] fileToDelete,
|
||||
Exception exception,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
if (!this.TaskCanceled(asyncOp) && exception == null)
|
||||
return;
|
||||
foreach (string path in fileToDelete)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
UserProcessBase.logger.ErrorFormat("Unable to delete the file {0}: {1}", (object) path, (object) ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static UserProcessProgressChangedEventArgs CreateProgressChangedEventArgs(
|
||||
string input,
|
||||
string output,
|
||||
long numberOfBytesToExtract,
|
||||
long remainingBytesToExtract,
|
||||
long totalNumberOfBytesToExtract,
|
||||
long totalNumberOfBytesRemaining,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
int progressPercentage = (int) ((double) (numberOfBytesToExtract - remainingBytesToExtract) / (double) numberOfBytesToExtract * 100.0);
|
||||
int totalProgressPercentage = (int) ((double) (totalNumberOfBytesToExtract - totalNumberOfBytesRemaining) / (double) totalNumberOfBytesToExtract * 100.0);
|
||||
return new UserProcessProgressChangedEventArgs(input, output, progressPercentage, totalProgressPercentage, asyncOp.UserSuppliedState);
|
||||
}
|
||||
|
||||
private static UserProcessWaitingForUserConsentEventArgs GetNotifyFileConflictEventArgs(
|
||||
string[] files,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
return (UserProcessWaitingForUserConsentEventArgs) new UserProcessWaitingForUserConsentFileConflictEventArgs(GDEmuConverter.FileConflictQuestionTitle, GDEmuConverter.FileConflictQuestionContent, files, (object) asyncOp);
|
||||
}
|
||||
|
||||
private void ReportPatchOrErrorDataEncoderProgress(
|
||||
DiscTrackCopyInfo track,
|
||||
AsyncOperation asyncOp)
|
||||
{
|
||||
this.ReportProgress(new UserProcessProgressChangedEventArgs(track.SourceTrack.FileName, track.DestinationTrack.FileName, 100, 100, asyncOp.UserSuppliedState), asyncOp);
|
||||
}
|
||||
|
||||
private void UpdateViewForBinaryPatcherStep(AsyncOperation asyncOp) => this.UpdateUIView(UserProcessUpdateUIViewEventArgs.OneProgressBarWithoutPercentage(GDEmuConverter.BinaryPatcherHint, false), asyncOp);
|
||||
|
||||
private void UpdateViewForDiscSectorEncoderStep(AsyncOperation asyncOp) => this.UpdateUIView(UserProcessUpdateUIViewEventArgs.OneProgressBarWithoutPercentage(GDEmuConverter.DiscSectorEncoderHint, false), asyncOp);
|
||||
|
||||
private delegate void FileConverterWorkerEventHandler(
|
||||
IDiscFileSystem imageFile,
|
||||
GDEmuExportOptions ExportOption,
|
||||
AsyncOperation asyncOp);
|
||||
}
|
||||
}
|
22
SEGATools/GDEmu/GDEmuExportOptions.cs
Normal file
22
SEGATools/GDEmu/GDEmuExportOptions.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
// Decompiled with JetBrains decompiler
|
||||
// Type: SEGATools.GDEmu.GDEmuExportOptions
|
||||
// Assembly: SEGATools, Version=1.0.3.0, Culture=neutral, PublicKeyToken=611be24fdeb07e08
|
||||
// MVID: D631183F-57B1-40A1-B502-5364D288307A
|
||||
// Assembly location: SEGATools.dll
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace SEGATools.GDEmu
|
||||
{
|
||||
public class GDEmuExportOptions
|
||||
{
|
||||
private static readonly string GDEMU_DEFAULT_DISC_IMAGE_FILE_NAME = "disc.gdi";
|
||||
public bool RegionFree;
|
||||
public bool ForceVGA;
|
||||
public string OutputPath;
|
||||
|
||||
public string GDIFileName => GDEmuExportOptions.GDEMU_DEFAULT_DISC_IMAGE_FILE_NAME;
|
||||
|
||||
public string GetOutputGDIFilePath() => Path.Combine(this.OutputPath, this.GDIFileName);
|
||||
}
|
||||
}
|
31
SEGATools/GDEmu/GDICreator.cs
Normal file
31
SEGATools/GDEmu/GDICreator.cs
Normal file
|
@ -0,0 +1,31 @@
|
|||
// Decompiled with JetBrains decompiler
|
||||
// Type: SEGATools.GDEmu.GDICreator
|
||||
// Assembly: SEGATools, Version=1.0.3.0, Culture=neutral, PublicKeyToken=611be24fdeb07e08
|
||||
// MVID: D631183F-57B1-40A1-B502-5364D288307A
|
||||
// Assembly location: SEGATools.dll
|
||||
|
||||
using SEGATools.DiscFileSystem;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace SEGATools.GDEmu
|
||||
{
|
||||
public class GDICreator
|
||||
{
|
||||
public static void CreateGDIFile(List<IDiscTrack> Tracks, string OutputGDIFile)
|
||||
{
|
||||
List<IDiscTrack> list = Tracks.OrderBy<IDiscTrack, uint>((Func<IDiscTrack, uint>) (track => track.LogicalBlockAddress)).ToList<IDiscTrack>();
|
||||
using (StreamWriter streamWriter = new StreamWriter(OutputGDIFile))
|
||||
{
|
||||
streamWriter.WriteLine(list.Count);
|
||||
for (int index = 0; index < list.Count; ++index)
|
||||
{
|
||||
IDiscTrack discTrack = list[index];
|
||||
streamWriter.WriteLine("{0} {1} {2} {3} {4} {5}", (object) (index + 1), (object) discTrack.LogicalBlockAddress, (object) (short) discTrack.TrackData, (object) discTrack.TrackSector.Size, (object) discTrack.VirtualName, (object) discTrack.Offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue