Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2022-06-01 19:59:00 -04:00
commit e10147e7bd
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
21 changed files with 297 additions and 103 deletions

View File

@ -1,7 +1,7 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<!--set general build properties -->
<Version>3.14.6</Version>
<Version>3.14.7</Version>
<Product>SMAPI</Product>
<LangVersion>latest</LangVersion>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
@ -69,8 +69,10 @@
<Copy SourceFiles="$(TargetDir)\MonoMod.Common.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<!-- .NET dependencies -->
<Copy SourceFiles="$(TargetDir)\System.Configuration.ConfigurationManager.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\System.Management.dll" DestinationFolder="$(GamePath)\smapi-internal" Condition="$(OS) == 'Windows_NT'" />
<!-- Legacy .NET dependencies (remove in SMAPI 4.0.0) -->
<Copy SourceFiles="$(TargetDir)\System.Configuration.ConfigurationManager.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\System.Runtime.Caching.dll" DestinationFolder="$(GamePath)\smapi-internal" />
<Copy SourceFiles="$(TargetDir)\System.Security.Permissions.dll" DestinationFolder="$(GamePath)\smapi-internal" />
</Target>

View File

@ -142,19 +142,20 @@ for folder in ${folders[@]}; do
cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
if [ $folder == "linux" ] || [ $folder == "macOS" ]; then
cp "$installAssets/unix-launcher.sh" "$bundlePath"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
else
cp "$installAssets/windows-exe-config.xml" "$bundlePath/StardewModdingAPI.exe.config"
fi
# copy .NET dependencies
cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal"
if [ $folder == "windows" ]; then
cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal"
fi
# copy legacy .NET dependencies (remove in SMAPI 4.0.0)
cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal"
# copy bundled mods
for modName in ${bundleModNames[@]}; do
fromPath="src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish"

View File

@ -162,20 +162,21 @@ foreach ($folder in $folders) {
cp "$smapiBin/SMAPI.metadata.json" "$bundlePath/smapi-internal/metadata.json"
if ($folder -eq "linux" -or $folder -eq "macOS") {
cp "$installAssets/unix-launcher.sh" "$bundlePath"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
}
else {
cp "$installAssets/windows-exe-config.xml" "$bundlePath/StardewModdingAPI.exe.config"
}
# copy .NET dependencies
cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal"
if ($folder -eq "windows") {
cp "$smapiBin/System.Management.dll" "$bundlePath/smapi-internal"
}
# copy legacy .NET dependencies (remove in SMAPI 4.0.0)
cp "$smapiBin/System.Configuration.ConfigurationManager.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Runtime.Caching.dll" "$bundlePath/smapi-internal"
cp "$smapiBin/System.Security.Permissions.dll" "$bundlePath/smapi-internal"
# copy bundled mods
foreach ($modName in $bundleModNames) {
$fromPath = "src/SMAPI.Mods.$modName/bin/$buildConfig/$runtime/publish"

View File

@ -1,6 +1,14 @@
← [README](README.md)
# Release notes
## 3.14.7
Released 01 June 2022 for Stardew Valley 1.5.6 or later.
* For players:
* Optimized reflection cache to reduce frame skips for some players.
* For mod authors:
* Removed `runtimeconfig.json` setting which impacted hot reload support.
## 3.14.6
Released 27 May 2022 for Stardew Valley 1.5.6 or later.

View File

@ -9,8 +9,9 @@
}
],
"configProperties": {
"System.Runtime.TieredCompilation": false,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false
// disable tiered runtime JIT: https://github.com/dotnet/runtime/blob/main/docs/design/features/tiered-compilation.md
// This is disabled by the base game, and causes issues with Harmony patches.
"System.Runtime.TieredCompilation": false
}
}
}

View File

@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
"Version": "3.14.6",
"Version": "3.14.7",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.14.6"
"MinimumApiVersion": "3.14.7"
}

View File

@ -1,9 +1,9 @@
{
"Name": "Error Handler",
"Author": "SMAPI",
"Version": "3.14.6",
"Version": "3.14.7",
"Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.",
"UniqueID": "SMAPI.ErrorHandler",
"EntryDll": "ErrorHandler.dll",
"MinimumApiVersion": "3.14.6"
"MinimumApiVersion": "3.14.7"
}

View File

@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
"Version": "3.14.6",
"Version": "3.14.7",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
"MinimumApiVersion": "3.14.6"
"MinimumApiVersion": "3.14.7"
}

View File

@ -35,6 +35,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
AccessesFilesystem = 128,
/// <summary>Uses .NET APIs for shell or process access.</summary>
AccessesShell = 256
AccessesShell = 256,
/// <summary>References the legacy <c>System.Configuration.ConfigurationManager</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyConfigurationDll = 512,
/// <summary>References the legacy <c>System.Runtime.Caching</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyCachingDll = 1024,
/// <summary>References the legacy <c>System.Security.Permissions</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyPermissionsDll = 2048
}
}

View File

@ -50,7 +50,7 @@ namespace StardewModdingAPI
internal static int? LogScreenId { get; set; }
/// <summary>SMAPI's current raw semantic version.</summary>
internal static string RawApiVersion = "3.14.6";
internal static string RawApiVersion = "3.14.7";
}
/// <summary>Contains SMAPI's constants and assumptions.</summary>

View File

@ -88,6 +88,10 @@ namespace StardewModdingAPI.Framework
/// <param name="warning">The warning to set.</param>
IModMetadata SetWarning(ModWarning warning);
/// <summary>Remove a warning flag for the mod.</summary>
/// <param name="warning">The warning to remove.</param>
IModMetadata RemoveWarning(ModWarning warning);
/// <summary>Set the mod instance.</summary>
/// <param name="mod">The mod instance to set.</param>
/// <param name="translations">The translations for this mod (if loaded).</param>

View File

@ -163,6 +163,29 @@ namespace StardewModdingAPI.Framework.ModLoading
this.AssemblyDefinitionResolver.Add(assembly.Definition);
}
// special case: clear legacy-DLL warnings if the mod bundles a copy
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Runtime.Caching.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyCachingDll);
else
{
// remove duplicate warnings (System.Runtime.Caching.dll references these)
mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll);
mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll);
}
}
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Configuration.ConfigurationManager.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyConfigurationDll);
}
if (mod.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll))
{
if (File.Exists(Path.Combine(mod.DirectoryPath, "System.Security.Permissions.dll")))
mod.RemoveWarning(ModWarning.DetectedLegacyPermissionsDll);
}
// throw if incompatibilities detected
if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded))
throw new IncompatibleInstructionException();
@ -429,6 +452,21 @@ namespace StardewModdingAPI.Framework.ModLoading
mod.SetWarning(ModWarning.AccessesShell);
break;
case InstructionHandleResult.DetectedLegacyCachingDll:
template = $"{logPrefix}Detected reference to System.Runtime.Caching.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyCachingDll);
break;
case InstructionHandleResult.DetectedLegacyConfigurationDll:
template = $"{logPrefix}Detected reference to System.Configuration.ConfigurationManager.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyConfigurationDll);
break;
case InstructionHandleResult.DetectedLegacyPermissionsDll:
template = $"{logPrefix}Detected reference to System.Security.Permissions.dll, which will be removed in SMAPI 4.0.0.";
mod.SetWarning(ModWarning.DetectedLegacyPermissionsDll);
break;
case InstructionHandleResult.None:
break;

View File

@ -0,0 +1,49 @@
using Mono.Cecil;
using StardewModdingAPI.Framework.ModLoading.Framework;
namespace StardewModdingAPI.Framework.ModLoading.Finders
{
/// <summary>Detects assembly references which will break in SMAPI 4.0.0.</summary>
internal class LegacyAssemblyFinder : BaseInstructionHandler
{
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public LegacyAssemblyFinder()
: base(defaultPhrase: "legacy assembly references") { }
/// <inheritdoc />
public override bool Handle(ModuleDefinition module)
{
foreach (AssemblyNameReference assembly in module.AssemblyReferences)
{
InstructionHandleResult flag = this.GetFlag(assembly);
if (flag is InstructionHandleResult.None)
continue;
this.MarkFlag(flag);
}
return false;
}
/*********
** Private methods
*********/
/// <summary>Get the instruction handle flag for the given assembly reference, if any.</summary>
/// <param name="assemblyRef">The assembly reference.</param>
private InstructionHandleResult GetFlag(AssemblyNameReference assemblyRef)
{
return assemblyRef.Name switch
{
"System.Configuration.ConfigurationManager" => InstructionHandleResult.DetectedLegacyConfigurationDll,
"System.Runtime.Caching" => InstructionHandleResult.DetectedLegacyCachingDll,
"System.Security.Permission" => InstructionHandleResult.DetectedLegacyPermissionsDll,
_ => InstructionHandleResult.None
};
}
}
}

View File

@ -30,6 +30,15 @@ namespace StardewModdingAPI.Framework.ModLoading
DetectedFilesystemAccess,
/// <summary>The instruction accesses the OS shell or processes directly.</summary>
DetectedShellAccess
DetectedShellAccess,
/// <summary>The module references the legacy <c>System.Configuration.ConfigurationManager</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyConfigurationDll,
/// <summary>The module references the legacy <c>System.Runtime.Caching</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyCachingDll,
/// <summary>The module references the legacy <c>System.Security.Permissions</c> assembly and doesn't include a copy in the mod folder, so it'll break in SMAPI 4.0.0.</summary>
DetectedLegacyPermissionsDll
}
}

View File

@ -138,6 +138,13 @@ namespace StardewModdingAPI.Framework.ModLoading
return this;
}
/// <inheritdoc />
public IModMetadata RemoveWarning(ModWarning warning)
{
this.ActualWarnings &= ~warning;
return this;
}
/// <inheritdoc />
public IModMetadata SetMod(IMod mod, TranslationHelper translations)
{

View File

@ -1,30 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace StardewModdingAPI.Framework.Reflection
{
/// <summary>A cached member reflection result.</summary>
internal readonly struct CacheEntry
{
/*********
** Accessors
*********/
/// <summary>Whether the lookup found a valid match.</summary>
[MemberNotNullWhen(true, nameof(CacheEntry.MemberInfo))]
public bool IsValid => this.MemberInfo != null;
/// <summary>The reflection data for this member (or <c>null</c> if invalid).</summary>
public MemberInfo? MemberInfo { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="memberInfo">The reflection data for this member (or <c>null</c> if invalid).</param>
public CacheEntry(MemberInfo? memberInfo)
{
this.MemberInfo = memberInfo;
}
}
}

View File

@ -1,6 +1,6 @@
using System;
using System.Reflection;
using System.Runtime.Caching;
using StardewModdingAPI.Framework.Utilities;
namespace StardewModdingAPI.Framework.Reflection
{
@ -12,10 +12,7 @@ namespace StardewModdingAPI.Framework.Reflection
** Fields
*********/
/// <summary>The cached fields and methods found via reflection.</summary>
private readonly MemoryCache Cache = new(typeof(Reflector).FullName!);
/// <summary>The sliding cache expiration time.</summary>
private readonly TimeSpan SlidingCacheExpiry = TimeSpan.FromMinutes(5);
private readonly IntervalMemoryCache<string, MemberInfo?> Cache = new();
/*********
@ -136,6 +133,15 @@ namespace StardewModdingAPI.Framework.Reflection
return method!;
}
/****
** Management
****/
/// <summary>Start a new cache interval, clearing stale reflection lookups.</summary>
public void NewCacheInterval()
{
this.Cache.StartNewInterval();
}
/*********
** Private methods
@ -149,20 +155,23 @@ namespace StardewModdingAPI.Framework.Reflection
private IReflectedField<TValue>? GetFieldFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
FieldInfo? field = this.GetCached($"field::{isStatic}::{type.FullName}::{name}", () =>
{
for (Type? curType = type; curType != null; curType = curType.BaseType)
FieldInfo? field = this.GetCached(
'f', type, name, isStatic,
fetch: () =>
{
FieldInfo? fieldInfo = curType.GetField(name, bindingFlags);
if (fieldInfo != null)
for (Type? curType = type; curType != null; curType = curType.BaseType)
{
type = curType;
return fieldInfo;
FieldInfo? fieldInfo = curType.GetField(name, bindingFlags);
if (fieldInfo != null)
{
type = curType;
return fieldInfo;
}
}
}
return null;
});
return null;
}
);
return field != null
? new ReflectedField<TValue>(type, obj, field, isStatic)
@ -178,20 +187,23 @@ namespace StardewModdingAPI.Framework.Reflection
private IReflectedProperty<TValue>? GetPropertyFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
PropertyInfo? property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () =>
{
for (Type? curType = type; curType != null; curType = curType.BaseType)
PropertyInfo? property = this.GetCached(
'p', type, name, isStatic,
fetch: () =>
{
PropertyInfo? propertyInfo = curType.GetProperty(name, bindingFlags);
if (propertyInfo != null)
for (Type? curType = type; curType != null; curType = curType.BaseType)
{
type = curType;
return propertyInfo;
PropertyInfo? propertyInfo = curType.GetProperty(name, bindingFlags);
if (propertyInfo != null)
{
type = curType;
return propertyInfo;
}
}
}
return null;
});
return null;
}
);
return property != null
? new ReflectedProperty<TValue>(type, obj, property, isStatic)
@ -206,47 +218,41 @@ namespace StardewModdingAPI.Framework.Reflection
private IReflectedMethod? GetMethodFromHierarchy(Type type, object? obj, string name, BindingFlags bindingFlags)
{
bool isStatic = bindingFlags.HasFlag(BindingFlags.Static);
MethodInfo? method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () =>
{
for (Type? curType = type; curType != null; curType = curType.BaseType)
MethodInfo? method = this.GetCached(
'm', type, name, isStatic,
fetch: () =>
{
MethodInfo? methodInfo = curType.GetMethod(name, bindingFlags);
if (methodInfo != null)
for (Type? curType = type; curType != null; curType = curType.BaseType)
{
type = curType;
return methodInfo;
MethodInfo? methodInfo = curType.GetMethod(name, bindingFlags);
if (methodInfo != null)
{
type = curType;
return methodInfo;
}
}
}
return null;
});
return null;
}
);
return method != null
? new ReflectedMethod(type, obj, method, isStatic: bindingFlags.HasFlag(BindingFlags.Static))
? new ReflectedMethod(type, obj, method, isStatic: isStatic)
: null;
}
/// <summary>Get a method or field through the cache.</summary>
/// <typeparam name="TMemberInfo">The expected <see cref="MemberInfo"/> type.</typeparam>
/// <param name="key">The cache key.</param>
/// <param name="memberType">A letter representing the member type (like 'm' for method).</param>
/// <param name="type">The type whose members are being reflected.</param>
/// <param name="memberName">The member name.</param>
/// <param name="isStatic">Whether the member is static.</param>
/// <param name="fetch">Fetches a new value to cache.</param>
private TMemberInfo? GetCached<TMemberInfo>(string key, Func<TMemberInfo?> fetch)
private TMemberInfo? GetCached<TMemberInfo>(char memberType, Type type, string memberName, bool isStatic, Func<TMemberInfo?> fetch)
where TMemberInfo : MemberInfo
{
// get from cache
if (this.Cache.Contains(key))
{
CacheEntry entry = (CacheEntry)this.Cache[key];
return entry.IsValid
? (TMemberInfo)entry.MemberInfo
: default;
}
// fetch & cache new value
TMemberInfo? result = fetch();
CacheEntry cacheEntry = new(result);
this.Cache.Add(key, cacheEntry, new CacheItemPolicy { SlidingExpiration = this.SlidingCacheExpiry });
return result;
string key = $"{memberType}{(isStatic ? 's' : 'i')}{type.FullName}:{memberName}";
return (TMemberInfo?)this.Cache.GetOrSet(key, fetch);
}
}
}

View File

@ -1164,6 +1164,8 @@ namespace StardewModdingAPI.Framework
protected void OnNewDayAfterFade()
{
this.EventManager.DayEnding.RaiseEmpty();
this.Reflection.NewCacheInterval();
}
/// <summary>A callback invoked after an asset is fully loaded through a content manager.</summary>
@ -1677,6 +1679,31 @@ namespace StardewModdingAPI.Framework
}
#pragma warning restore CS0612, CS0618
// log deprecation warnings
if (metadata.HasWarnings(ModWarning.DetectedLegacyCachingDll, ModWarning.DetectedLegacyConfigurationDll, ModWarning.DetectedLegacyPermissionsDll))
{
string?[] referenced =
new[]
{
metadata.Warnings.HasFlag(ModWarning.DetectedLegacyConfigurationDll) ? "System.Configuration.ConfigurationManager" : null,
metadata.Warnings.HasFlag(ModWarning.DetectedLegacyCachingDll) ? "System.Runtime.Caching" : null,
metadata.Warnings.HasFlag(ModWarning.DetectedLegacyPermissionsDll) ? "System.Security.Permissions" : null
}
.Where(p => p is not null)
.ToArray();
foreach (string? name in referenced)
{
DeprecationManager.Warn(
metadata,
$"using {name} without bundling it",
"3.14.7",
DeprecationLevel.Notice,
logStackTrace: false
);
}
}
// call entry method
Context.HeuristicModsRunningCode.Push(metadata);
try

View File

@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
namespace StardewModdingAPI.Framework.Utilities
{
/// <summary>A memory cache with sliding expiry based on custom intervals, with no background processing.</summary>
/// <typeparam name="TKey">The cache key type.</typeparam>
/// <typeparam name="TValue">The cache value type.</typeparam>
/// <remarks>This is optimized for small caches that are reset relatively rarely. Each cache entry is marked as hot (accessed since the interval started) or stale.
/// When a new interval is started, stale entries are cleared and hot entries become stale.</remarks>
internal class IntervalMemoryCache<TKey, TValue>
where TKey : notnull
{
/*********
** Fields
*********/
/// <summary>The cached values that were accessed during the current interval.</summary>
private Dictionary<TKey, TValue> HotCache = new();
/// <summary>The cached values that will expire on the next interval.</summary>
private Dictionary<TKey, TValue> StaleCache = new();
/*********
** Public methods
*********/
/// <summary>Get a value from the cache, fetching it first if needed.</summary>
/// <param name="cacheKey">The unique key for the cached value.</param>
/// <param name="get">Get the latest data if it's not in the cache yet.</param>
public TValue GetOrSet(TKey cacheKey, Func<TValue> get)
{
// from hot cache
if (this.HotCache.TryGetValue(cacheKey, out TValue? value))
return value;
// from stale cache
if (this.StaleCache.TryGetValue(cacheKey, out value))
{
this.HotCache[cacheKey] = value;
return value;
}
// new value
value = get();
this.HotCache[cacheKey] = value;
return value;
}
/// <summary>Start a new cache interval, removing any stale entries.</summary>
public void StartNewInterval()
{
this.StaleCache.Clear();
if (this.HotCache.Count is not 0)
(this.StaleCache, this.HotCache) = (this.HotCache, this.StaleCache); // swap hot cache to stale
}
}
}

View File

@ -53,6 +53,9 @@ namespace StardewModdingAPI.Metadata
// detect Harmony & rewrite for SMAPI 3.12 (Harmony 1.x => 2.0 update)
yield return new HarmonyRewriter();
// detect issues for SMAPI 4.0.0
yield return new LegacyAssemblyFinder();
}
else
yield return new HarmonyRewriter(shouldRewrite: false);

View File

@ -28,6 +28,8 @@
<PackageReference Include="Pintail" Version="2.1.0" />
<PackageReference Include="Platonymous.TMXTile" Version="1.5.9" />
<PackageReference Include="System.Reflection.Emit" Version="4.7.0" />
<!-- legacy package; remove in SMAPI 4.0.0 -->
<PackageReference Include="System.Runtime.Caching" Version="5.0.0" />
</ItemGroup>