551 lines
20 KiB
C#
551 lines
20 KiB
C#
|
#if UNITY_EDITOR
|
||
|
using System;
|
||
|
using System.Collections.Generic;
|
||
|
using UnityEditor;
|
||
|
using UnityEngine.ResourceManagement.AsyncOperations;
|
||
|
using UnityEngine.ResourceManagement.Exceptions;
|
||
|
using UnityEngine.ResourceManagement.ResourceLocations;
|
||
|
using UnityEngine.ResourceManagement.Util;
|
||
|
using UnityEngine.Serialization;
|
||
|
|
||
|
namespace UnityEngine.ResourceManagement.ResourceProviders.Simulation
|
||
|
{
|
||
|
abstract class VBAsyncOperation
|
||
|
{
|
||
|
public abstract DownloadStatus GetDownloadStatus();
|
||
|
public abstract bool WaitForCompletion();
|
||
|
}
|
||
|
|
||
|
class VBAsyncOperation<TObject> : VBAsyncOperation
|
||
|
{
|
||
|
protected TObject m_Result;
|
||
|
protected AsyncOperationStatus m_Status;
|
||
|
protected Exception m_Error;
|
||
|
protected object m_Context;
|
||
|
|
||
|
DelegateList<VBAsyncOperation<TObject>> m_CompletedAction;
|
||
|
Action<VBAsyncOperation<TObject>> m_OnDestroyAction;
|
||
|
|
||
|
public override DownloadStatus GetDownloadStatus() => default;
|
||
|
public override bool WaitForCompletion() => true;
|
||
|
|
||
|
public override string ToString()
|
||
|
{
|
||
|
var instId = "";
|
||
|
var or = m_Result as Object;
|
||
|
if (or != null)
|
||
|
instId = "(" + or.GetInstanceID() + ")";
|
||
|
return string.Format("{0}, result='{1}', status='{2}', location={3}.", base.ToString(), (m_Result + instId), m_Status, m_Context);
|
||
|
}
|
||
|
|
||
|
public event Action<VBAsyncOperation<TObject>> Completed
|
||
|
{
|
||
|
add
|
||
|
{
|
||
|
if (IsDone)
|
||
|
{
|
||
|
DelayedActionManager.AddAction(value, 0, this);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
if (m_CompletedAction == null)
|
||
|
m_CompletedAction = DelegateList<VBAsyncOperation<TObject>>.CreateWithGlobalCache();
|
||
|
m_CompletedAction.Add(value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
remove
|
||
|
{
|
||
|
m_CompletedAction.Remove(value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public AsyncOperationStatus Status { get { return m_Status; } protected set { m_Status = value; } }
|
||
|
/// <inheritdoc />
|
||
|
public Exception OperationException
|
||
|
{
|
||
|
get { return m_Error; }
|
||
|
protected set
|
||
|
{
|
||
|
m_Error = value;
|
||
|
if (m_Error != null && ResourceManager.ExceptionHandler != null)
|
||
|
ResourceManager.ExceptionHandler(new AsyncOperationHandle(null), value);
|
||
|
}
|
||
|
}
|
||
|
public TObject Result { get { return m_Result; } }
|
||
|
public virtual bool IsDone { get { return Status == AsyncOperationStatus.Failed || Status == AsyncOperationStatus.Succeeded; } }
|
||
|
/// <inheritdoc />
|
||
|
public virtual float PercentComplete { get { return IsDone ? 1f : 0f; } }
|
||
|
/// <inheritdoc />
|
||
|
public object Context { get { return m_Context; } set { m_Context = value; } }
|
||
|
|
||
|
public void InvokeCompletionEvent()
|
||
|
{
|
||
|
if (m_CompletedAction != null)
|
||
|
{
|
||
|
m_CompletedAction.Invoke(this);
|
||
|
m_CompletedAction.Clear();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
public virtual void SetResult(TObject result)
|
||
|
{
|
||
|
m_Result = result;
|
||
|
m_Status = (m_Result == null) ? AsyncOperationStatus.Failed : AsyncOperationStatus.Succeeded;
|
||
|
}
|
||
|
|
||
|
public VBAsyncOperation<TObject> StartCompleted(object context, object key, TObject val, Exception error = null)
|
||
|
{
|
||
|
Context = context;
|
||
|
OperationException = error;
|
||
|
m_Result = val;
|
||
|
m_Status = (m_Result == null) ? AsyncOperationStatus.Failed : AsyncOperationStatus.Succeeded;
|
||
|
DelayedActionManager.AddAction((Action)InvokeCompletionEvent);
|
||
|
return this;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
/// <summary>
|
||
|
/// Contains data needed to simulate a bundled asset
|
||
|
/// </summary>
|
||
|
[Serializable]
|
||
|
public class VirtualAssetBundleEntry
|
||
|
{
|
||
|
[FormerlySerializedAs("m_name")]
|
||
|
[SerializeField]
|
||
|
string m_Name;
|
||
|
/// <summary>
|
||
|
/// The name of the asset.
|
||
|
/// </summary>
|
||
|
public string Name { get { return m_Name; } }
|
||
|
[FormerlySerializedAs("m_size")]
|
||
|
[SerializeField]
|
||
|
long m_Size;
|
||
|
/// <summary>
|
||
|
/// The file size of the asset, in bytes.
|
||
|
/// </summary>
|
||
|
public long Size { get { return m_Size; } }
|
||
|
|
||
|
[SerializeField]
|
||
|
internal string m_AssetPath;
|
||
|
/// <summary>
|
||
|
/// Construct a new VirtualAssetBundleEntry
|
||
|
/// </summary>
|
||
|
public VirtualAssetBundleEntry() {}
|
||
|
/// <summary>
|
||
|
/// Construct a new VirtualAssetBundleEntry
|
||
|
/// </summary>
|
||
|
/// <param name="name">The name of the asset.</param>
|
||
|
/// <param name="size">The size of the asset, in bytes.</param>
|
||
|
public VirtualAssetBundleEntry(string name, long size)
|
||
|
{
|
||
|
m_Name = name;
|
||
|
m_Size = size;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Contains data need to simulate an asset bundle.
|
||
|
/// </summary>
|
||
|
[Serializable]
|
||
|
public class VirtualAssetBundle : ISerializationCallbackReceiver, IAssetBundleResource
|
||
|
{
|
||
|
[FormerlySerializedAs("m_name")]
|
||
|
[SerializeField]
|
||
|
string m_Name;
|
||
|
[FormerlySerializedAs("m_isLocal")]
|
||
|
[SerializeField]
|
||
|
bool m_IsLocal;
|
||
|
[FormerlySerializedAs("m_dataSize")]
|
||
|
[SerializeField]
|
||
|
long m_DataSize;
|
||
|
[FormerlySerializedAs("m_headerSize")]
|
||
|
[SerializeField]
|
||
|
long m_HeaderSize;
|
||
|
[FormerlySerializedAs("m_latency")]
|
||
|
[SerializeField]
|
||
|
float m_Latency;
|
||
|
[SerializeField]
|
||
|
uint m_Crc;
|
||
|
[SerializeField]
|
||
|
string m_Hash;
|
||
|
|
||
|
[FormerlySerializedAs("m_serializedAssets")]
|
||
|
[SerializeField]
|
||
|
List<VirtualAssetBundleEntry> m_SerializedAssets = new List<VirtualAssetBundleEntry>();
|
||
|
|
||
|
long m_HeaderBytesLoaded;
|
||
|
long m_DataBytesLoaded;
|
||
|
|
||
|
LoadAssetBundleOp m_BundleLoadOperation;
|
||
|
List<IVirtualLoadable> m_AssetLoadOperations = new List<IVirtualLoadable>();
|
||
|
Dictionary<string, VirtualAssetBundleEntry> m_AssetMap;
|
||
|
/// <summary>
|
||
|
/// The name of the bundle.
|
||
|
/// </summary>
|
||
|
public string Name { get { return m_Name; } }
|
||
|
/// <summary>
|
||
|
/// The assets contained in the bundle.
|
||
|
/// </summary>
|
||
|
public List<VirtualAssetBundleEntry> Assets { get { return m_SerializedAssets; } }
|
||
|
|
||
|
const long k_SynchronousBytesPerSecond = (long) 1024 * 1024 * 1024 * 10; // 10 Gb/s
|
||
|
|
||
|
/// <summary>
|
||
|
/// Construct a new VirtualAssetBundle object.
|
||
|
/// </summary>
|
||
|
public VirtualAssetBundle()
|
||
|
{
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// The percent of data that has been loaded.
|
||
|
/// </summary>
|
||
|
public float PercentComplete
|
||
|
{
|
||
|
get
|
||
|
{
|
||
|
if (m_HeaderSize + m_DataSize <= 0)
|
||
|
return 1;
|
||
|
|
||
|
return (float)(m_HeaderBytesLoaded + m_DataBytesLoaded) / (m_HeaderSize + m_DataSize);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Construct a new VirtualAssetBundle
|
||
|
/// </summary>
|
||
|
/// <param name="name">The name of the bundle.</param>
|
||
|
/// <param name="local">Is the bundle local or remote. This is used to determine which bandwidth value to use when simulating loading.</param>
|
||
|
public VirtualAssetBundle(string name, bool local, uint crc, string hash)
|
||
|
{
|
||
|
m_Latency = .1f;
|
||
|
m_Name = name;
|
||
|
m_IsLocal = local;
|
||
|
m_HeaderBytesLoaded = 0;
|
||
|
m_DataBytesLoaded = 0;
|
||
|
m_Crc = crc;
|
||
|
m_Hash = hash;
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Set the size of the bundle.
|
||
|
/// </summary>
|
||
|
/// <param name="dataSize">The size of the data.</param>
|
||
|
/// <param name="headerSize">The size of the header.</param>
|
||
|
public void SetSize(long dataSize, long headerSize)
|
||
|
{
|
||
|
m_HeaderSize = headerSize;
|
||
|
m_DataSize = dataSize;
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Not used
|
||
|
/// </summary>
|
||
|
public void OnBeforeSerialize()
|
||
|
{
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Load serialized data into runtime structures.
|
||
|
/// </summary>
|
||
|
public void OnAfterDeserialize()
|
||
|
{
|
||
|
m_AssetMap = new Dictionary<string, VirtualAssetBundleEntry>();
|
||
|
foreach (var a in m_SerializedAssets)
|
||
|
m_AssetMap.Add(a.Name, a);
|
||
|
}
|
||
|
|
||
|
class LoadAssetBundleOp : VBAsyncOperation<VirtualAssetBundle>
|
||
|
{
|
||
|
VirtualAssetBundle m_Bundle;
|
||
|
float m_TimeInLoadingState;
|
||
|
bool m_crcHashValidated;
|
||
|
public LoadAssetBundleOp(IResourceLocation location, VirtualAssetBundle bundle)
|
||
|
{
|
||
|
Context = location;
|
||
|
m_Bundle = bundle;
|
||
|
m_TimeInLoadingState = 0.0f;
|
||
|
}
|
||
|
|
||
|
public override bool WaitForCompletion()
|
||
|
{
|
||
|
SetResult(m_Bundle);
|
||
|
InvokeCompletionEvent();
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
public override DownloadStatus GetDownloadStatus()
|
||
|
{
|
||
|
if (m_Bundle.m_IsLocal)
|
||
|
return new DownloadStatus() { IsDone = IsDone };
|
||
|
return new DownloadStatus() { DownloadedBytes = m_Bundle.m_DataBytesLoaded, TotalBytes = m_Bundle.m_DataSize, IsDone = IsDone };
|
||
|
}
|
||
|
|
||
|
public override float PercentComplete
|
||
|
{
|
||
|
get
|
||
|
{
|
||
|
if (IsDone)
|
||
|
return 1f;
|
||
|
return m_Bundle.PercentComplete;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public void Update(long localBandwidth, long remoteBandwidth, float unscaledDeltaTime)
|
||
|
{
|
||
|
if (m_Result != null)
|
||
|
return;
|
||
|
|
||
|
if (!m_crcHashValidated)
|
||
|
{
|
||
|
var location = Context as IResourceLocation;
|
||
|
var reqOptions = location.Data as AssetBundleRequestOptions;
|
||
|
if (reqOptions != null)
|
||
|
{
|
||
|
if (reqOptions.Crc != 0 && m_Bundle.m_Crc != reqOptions.Crc)
|
||
|
{
|
||
|
var err = string.Format("Error while downloading Asset Bundle: CRC Mismatch. Provided {0}, calculated {1} from data. Will not load Asset Bundle.", reqOptions.Crc, m_Bundle.m_Crc);
|
||
|
SetResult(null);
|
||
|
OperationException = new Exception(err);
|
||
|
InvokeCompletionEvent();
|
||
|
}
|
||
|
if (!m_Bundle.m_IsLocal)
|
||
|
{
|
||
|
if (!string.IsNullOrEmpty(reqOptions.Hash))
|
||
|
{
|
||
|
if (string.IsNullOrEmpty(m_Bundle.m_Hash) || m_Bundle.m_Hash != reqOptions.Hash)
|
||
|
{
|
||
|
Debug.LogWarningFormat("Mismatched hash in bundle {0}.", m_Bundle.Name);
|
||
|
}
|
||
|
//TODO: implement virtual cache that would persist between runs.
|
||
|
//if(vCache.hashBundle(m_Bundle.Name, reqOptions.Hash))
|
||
|
// m_m_Bundle.IsLocal = true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
m_crcHashValidated = true;
|
||
|
}
|
||
|
|
||
|
m_TimeInLoadingState += unscaledDeltaTime;
|
||
|
if (m_TimeInLoadingState > m_Bundle.m_Latency)
|
||
|
{
|
||
|
long localBytes = (long)Math.Ceiling(unscaledDeltaTime * localBandwidth);
|
||
|
long remoteBytes = (long)Math.Ceiling(unscaledDeltaTime * remoteBandwidth);
|
||
|
|
||
|
if (m_Bundle.LoadData(localBytes, remoteBytes))
|
||
|
{
|
||
|
SetResult(m_Bundle);
|
||
|
InvokeCompletionEvent();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
bool LoadData(long localBytes, long remoteBytes)
|
||
|
{
|
||
|
if (m_IsLocal)
|
||
|
{
|
||
|
m_HeaderBytesLoaded += localBytes;
|
||
|
if (m_HeaderBytesLoaded < m_HeaderSize)
|
||
|
return false;
|
||
|
m_HeaderBytesLoaded = m_HeaderSize;
|
||
|
return true;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
if (m_DataBytesLoaded < m_DataSize)
|
||
|
{
|
||
|
m_DataBytesLoaded += remoteBytes;
|
||
|
if (m_DataBytesLoaded < m_DataSize)
|
||
|
return false;
|
||
|
m_DataBytesLoaded = m_DataSize;
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
m_HeaderBytesLoaded += localBytes;
|
||
|
if (m_HeaderBytesLoaded < m_HeaderSize)
|
||
|
return false;
|
||
|
m_HeaderBytesLoaded = m_HeaderSize;
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
internal bool Unload()
|
||
|
{
|
||
|
if (m_BundleLoadOperation == null)
|
||
|
Debug.LogWarningFormat("Simulated assetbundle {0} is already unloaded.", m_Name);
|
||
|
m_HeaderBytesLoaded = 0;
|
||
|
m_BundleLoadOperation = null;
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
internal VBAsyncOperation<VirtualAssetBundle> StartLoad(IResourceLocation location)
|
||
|
{
|
||
|
if (m_BundleLoadOperation != null)
|
||
|
{
|
||
|
if (m_BundleLoadOperation.IsDone)
|
||
|
Debug.LogWarningFormat("Simulated assetbundle {0} is already loaded.", m_Name);
|
||
|
else
|
||
|
Debug.LogWarningFormat("Simulated assetbundle {0} is already loading.", m_Name);
|
||
|
return m_BundleLoadOperation;
|
||
|
}
|
||
|
m_HeaderBytesLoaded = 0;
|
||
|
return (m_BundleLoadOperation = new LoadAssetBundleOp(location, this));
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Load an asset via its location. The asset will actually be loaded via the AssetDatabase API.
|
||
|
/// </summary>
|
||
|
/// <typeparam name="TObject"></typeparam>
|
||
|
/// <param name="location"></param>
|
||
|
/// <returns></returns>
|
||
|
internal VBAsyncOperation<object> LoadAssetAsync(ProvideHandle provideHandle, IResourceLocation location)
|
||
|
{
|
||
|
if (location == null)
|
||
|
throw new ArgumentException("IResourceLocation location cannot be null.");
|
||
|
if (m_BundleLoadOperation == null)
|
||
|
return new VBAsyncOperation<object>().StartCompleted(location, location, null, new ResourceManagerException("LoadAssetAsync called on unloaded bundle " + m_Name));
|
||
|
|
||
|
if (!m_BundleLoadOperation.IsDone)
|
||
|
return new VBAsyncOperation<object>().StartCompleted(location, location, null, new ResourceManagerException("LoadAssetAsync called on loading bundle " + m_Name));
|
||
|
VirtualAssetBundleEntry assetInfo;
|
||
|
var assetPath = location.InternalId;
|
||
|
if (ResourceManagerConfig.ExtractKeyAndSubKey(assetPath, out string mainPath, out string subKey))
|
||
|
assetPath = mainPath;
|
||
|
|
||
|
//this needs to use the non translated internal id since that was how the table was built.
|
||
|
if (!m_AssetMap.TryGetValue(assetPath, out assetInfo))
|
||
|
return new VBAsyncOperation<object>().StartCompleted(location, location, null, new ResourceManagerException(string.Format("Unable to load asset {0} from simulated bundle {1}.", location.InternalId, Name)));
|
||
|
|
||
|
var op = new LoadAssetOp(location, assetInfo, provideHandle);
|
||
|
m_AssetLoadOperations.Add(op);
|
||
|
return op;
|
||
|
}
|
||
|
|
||
|
internal void CountBandwidthUsage(ref long localCount, ref long remoteCount)
|
||
|
{
|
||
|
if (m_BundleLoadOperation != null && m_BundleLoadOperation.IsDone)
|
||
|
{
|
||
|
localCount += m_AssetLoadOperations.Count;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (m_IsLocal)
|
||
|
{
|
||
|
localCount++;
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
if (m_DataBytesLoaded < m_DataSize)
|
||
|
remoteCount++;
|
||
|
else
|
||
|
localCount++;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
interface IVirtualLoadable
|
||
|
{
|
||
|
bool Load(long localBandwidth, long remoteBandwidth, float unscaledDeltaTime);
|
||
|
}
|
||
|
|
||
|
// TODO: This is only needed internally. We can change this to not derive off of AsyncOperationBase and simplify the code
|
||
|
class LoadAssetOp : VBAsyncOperation<object>, IVirtualLoadable
|
||
|
{
|
||
|
long m_BytesLoaded;
|
||
|
float m_LastUpdateTime;
|
||
|
VirtualAssetBundleEntry m_AssetInfo;
|
||
|
ProvideHandle m_provideHandle;
|
||
|
public LoadAssetOp(IResourceLocation location, VirtualAssetBundleEntry assetInfo, ProvideHandle ph)
|
||
|
{
|
||
|
m_provideHandle = ph;
|
||
|
Context = location;
|
||
|
m_AssetInfo = assetInfo;
|
||
|
m_LastUpdateTime = Time.realtimeSinceStartup;
|
||
|
}
|
||
|
|
||
|
public override bool WaitForCompletion()
|
||
|
{
|
||
|
//TODO: this needs to just wait on the resourcemanager update loop to ensure proper loading order
|
||
|
while (!IsDone)
|
||
|
{
|
||
|
Load(k_SynchronousBytesPerSecond, k_SynchronousBytesPerSecond, .1f);
|
||
|
System.Threading.Thread.Sleep(100);
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
public override float PercentComplete { get { return Mathf.Clamp01(m_BytesLoaded / (float)m_AssetInfo.Size); } }
|
||
|
public bool Load(long localBandwidth, long remoteBandwidth, float unscaledDeltaTime)
|
||
|
{
|
||
|
if (IsDone)
|
||
|
return false;
|
||
|
var now = m_LastUpdateTime + unscaledDeltaTime;
|
||
|
if (now > m_LastUpdateTime)
|
||
|
{
|
||
|
m_BytesLoaded += (long)Math.Ceiling((now - m_LastUpdateTime) * localBandwidth);
|
||
|
m_LastUpdateTime = now;
|
||
|
}
|
||
|
if (m_BytesLoaded < m_AssetInfo.Size)
|
||
|
return true;
|
||
|
if (!(Context is IResourceLocation))
|
||
|
return false;
|
||
|
var location = Context as IResourceLocation;
|
||
|
var assetPath = m_AssetInfo.m_AssetPath;
|
||
|
object result = null;
|
||
|
|
||
|
var pt = m_provideHandle.Type;
|
||
|
if (pt.IsArray)
|
||
|
result = ResourceManagerConfig.CreateArrayResult(pt, AssetDatabaseProvider.LoadAssetsWithSubAssets(assetPath));
|
||
|
else if (pt.IsGenericType && typeof(IList<>) == pt.GetGenericTypeDefinition())
|
||
|
result = ResourceManagerConfig.CreateListResult(pt, AssetDatabaseProvider.LoadAssetsWithSubAssets(assetPath));
|
||
|
else
|
||
|
{
|
||
|
if (ResourceManagerConfig.ExtractKeyAndSubKey(location.InternalId, out string mainPath, out string subKey))
|
||
|
result = AssetDatabaseProvider.LoadAssetSubObject(assetPath, subKey, pt);
|
||
|
else
|
||
|
result = AssetDatabaseProvider.LoadAssetAtPath(assetPath, m_provideHandle);
|
||
|
}
|
||
|
SetResult(result);
|
||
|
InvokeCompletionEvent();
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
//return true until complete
|
||
|
internal bool UpdateAsyncOperations(long localBandwidth, long remoteBandwidth, float unscaledDeltaTime)
|
||
|
{
|
||
|
if (m_BundleLoadOperation == null)
|
||
|
return false;
|
||
|
|
||
|
if (!m_BundleLoadOperation.IsDone)
|
||
|
{
|
||
|
m_BundleLoadOperation.Update(localBandwidth, remoteBandwidth, unscaledDeltaTime);
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
foreach (var o in m_AssetLoadOperations)
|
||
|
{
|
||
|
if (!o.Load(localBandwidth, remoteBandwidth, unscaledDeltaTime))
|
||
|
{
|
||
|
m_AssetLoadOperations.Remove(o);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
return m_AssetLoadOperations.Count > 0;
|
||
|
}
|
||
|
|
||
|
/// <summary>
|
||
|
/// Implementation of IAssetBundleResource API
|
||
|
/// </summary>
|
||
|
/// <returns>Always returns null.</returns>
|
||
|
public AssetBundle GetAssetBundle()
|
||
|
{
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
#endif
|