Size hints for copy regions and viewport dimensions to avoid data loss (#1686)
* Size hints for copy regions and viewport dimensions to avoid data loss * Reword comment. * Use info for the rule rather than calculating aligned size. * Reorder min/max, remove spaces
This commit is contained in:
parent
c3d62bd078
commit
02872833b6
5 changed files with 111 additions and 45 deletions
|
@ -1,6 +1,7 @@
|
||||||
using Ryujinx.Graphics.GAL;
|
using Ryujinx.Graphics.GAL;
|
||||||
using Ryujinx.Graphics.Gpu.Image;
|
using Ryujinx.Graphics.Gpu.Image;
|
||||||
using Ryujinx.Graphics.Gpu.State;
|
using Ryujinx.Graphics.Gpu.State;
|
||||||
|
using Ryujinx.Graphics.Texture;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Ryujinx.Graphics.Gpu.Engine
|
namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
|
@ -19,9 +20,30 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
var dstCopyTexture = state.Get<CopyTexture>(MethodOffset.CopyDstTexture);
|
var dstCopyTexture = state.Get<CopyTexture>(MethodOffset.CopyDstTexture);
|
||||||
var srcCopyTexture = state.Get<CopyTexture>(MethodOffset.CopySrcTexture);
|
var srcCopyTexture = state.Get<CopyTexture>(MethodOffset.CopySrcTexture);
|
||||||
|
|
||||||
|
var region = state.Get<CopyRegion>(MethodOffset.CopyRegion);
|
||||||
|
|
||||||
|
var control = state.Get<CopyTextureControl>(MethodOffset.CopyTextureControl);
|
||||||
|
|
||||||
|
int srcX1 = (int)(region.SrcXF >> 32);
|
||||||
|
int srcY1 = (int)(region.SrcYF >> 32);
|
||||||
|
|
||||||
|
int srcX2 = (int)((region.SrcXF + region.SrcWidthRF * region.DstWidth) >> 32);
|
||||||
|
int srcY2 = (int)((region.SrcYF + region.SrcHeightRF * region.DstHeight) >> 32);
|
||||||
|
|
||||||
|
int dstX1 = region.DstX;
|
||||||
|
int dstY1 = region.DstY;
|
||||||
|
|
||||||
|
int dstX2 = region.DstX + region.DstWidth;
|
||||||
|
int dstY2 = region.DstY + region.DstHeight;
|
||||||
|
|
||||||
|
// The source and destination textures should at least be as big as the region being requested.
|
||||||
|
// The hints will only resize within alignment constraints, so out of bound copies won't resize in most cases.
|
||||||
|
var srcHint = new Size(srcX2, srcY2, 1);
|
||||||
|
var dstHint = new Size(dstX2, dstY2, 1);
|
||||||
|
|
||||||
var srcCopyTextureFormat = srcCopyTexture.Format.Convert();
|
var srcCopyTextureFormat = srcCopyTexture.Format.Convert();
|
||||||
|
|
||||||
Texture srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture, srcCopyTextureFormat);
|
Texture srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture, srcCopyTextureFormat, true, srcHint);
|
||||||
|
|
||||||
if (srcTexture == null)
|
if (srcTexture == null)
|
||||||
{
|
{
|
||||||
|
@ -42,7 +64,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
dstCopyTextureFormat = dstCopyTexture.Format.Convert();
|
dstCopyTextureFormat = dstCopyTexture.Format.Convert();
|
||||||
}
|
}
|
||||||
|
|
||||||
Texture dstTexture = TextureManager.FindOrCreateTexture(dstCopyTexture, dstCopyTextureFormat, srcTexture.ScaleMode == TextureScaleMode.Scaled);
|
Texture dstTexture = TextureManager.FindOrCreateTexture(dstCopyTexture, dstCopyTextureFormat, srcTexture.ScaleMode == TextureScaleMode.Scaled, dstHint);
|
||||||
|
|
||||||
if (dstTexture == null)
|
if (dstTexture == null)
|
||||||
{
|
{
|
||||||
|
@ -54,22 +76,6 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
srcTexture.PropagateScale(dstTexture);
|
srcTexture.PropagateScale(dstTexture);
|
||||||
}
|
}
|
||||||
|
|
||||||
var control = state.Get<CopyTextureControl>(MethodOffset.CopyTextureControl);
|
|
||||||
|
|
||||||
var region = state.Get<CopyRegion>(MethodOffset.CopyRegion);
|
|
||||||
|
|
||||||
int srcX1 = (int)(region.SrcXF >> 32);
|
|
||||||
int srcY1 = (int)(region.SrcYF >> 32);
|
|
||||||
|
|
||||||
int srcX2 = (int)((region.SrcXF + region.SrcWidthRF * region.DstWidth) >> 32);
|
|
||||||
int srcY2 = (int)((region.SrcYF + region.SrcHeightRF * region.DstHeight) >> 32);
|
|
||||||
|
|
||||||
int dstX1 = region.DstX;
|
|
||||||
int dstY1 = region.DstY;
|
|
||||||
|
|
||||||
int dstX2 = region.DstX + region.DstWidth;
|
|
||||||
int dstY2 = region.DstY + region.DstHeight;
|
|
||||||
|
|
||||||
float scale = srcTexture.ScaleFactor; // src and dest scales are identical now.
|
float scale = srcTexture.ScaleFactor; // src and dest scales are identical now.
|
||||||
|
|
||||||
Extents2D srcRegion = new Extents2D(
|
Extents2D srcRegion = new Extents2D(
|
||||||
|
@ -100,7 +106,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
{
|
{
|
||||||
srcCopyTexture.Height++;
|
srcCopyTexture.Height++;
|
||||||
|
|
||||||
srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture, srcCopyTextureFormat, srcTexture.ScaleMode == TextureScaleMode.Scaled);
|
srcTexture = TextureManager.FindOrCreateTexture(srcCopyTexture, srcCopyTextureFormat, srcTexture.ScaleMode == TextureScaleMode.Scaled, srcHint);
|
||||||
if (srcTexture.ScaleFactor != dstTexture.ScaleFactor)
|
if (srcTexture.ScaleFactor != dstTexture.ScaleFactor)
|
||||||
{
|
{
|
||||||
srcTexture.PropagateScale(dstTexture);
|
srcTexture.PropagateScale(dstTexture);
|
||||||
|
|
|
@ -5,6 +5,7 @@ using Ryujinx.Graphics.Gpu.Memory;
|
||||||
using Ryujinx.Graphics.Gpu.Shader;
|
using Ryujinx.Graphics.Gpu.Shader;
|
||||||
using Ryujinx.Graphics.Gpu.State;
|
using Ryujinx.Graphics.Gpu.State;
|
||||||
using Ryujinx.Graphics.Shader;
|
using Ryujinx.Graphics.Shader;
|
||||||
|
using Ryujinx.Graphics.Texture;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
@ -354,6 +355,9 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
int samplesInX = msaaMode.SamplesInX();
|
int samplesInX = msaaMode.SamplesInX();
|
||||||
int samplesInY = msaaMode.SamplesInY();
|
int samplesInY = msaaMode.SamplesInY();
|
||||||
|
|
||||||
|
var extents = state.Get<ViewportExtents>(MethodOffset.ViewportExtents, 0);
|
||||||
|
Size sizeHint = new Size(extents.X + extents.Width, extents.Y + extents.Height, 1);
|
||||||
|
|
||||||
bool changedScale = false;
|
bool changedScale = false;
|
||||||
|
|
||||||
for (int index = 0; index < Constants.TotalRenderTargets; index++)
|
for (int index = 0; index < Constants.TotalRenderTargets; index++)
|
||||||
|
@ -369,7 +373,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Texture color = TextureManager.FindOrCreateTexture(colorState, samplesInX, samplesInY);
|
Texture color = TextureManager.FindOrCreateTexture(colorState, samplesInX, samplesInY, sizeHint);
|
||||||
|
|
||||||
changedScale |= TextureManager.SetRenderTargetColor(index, color);
|
changedScale |= TextureManager.SetRenderTargetColor(index, color);
|
||||||
|
|
||||||
|
@ -388,7 +392,7 @@ namespace Ryujinx.Graphics.Gpu.Engine
|
||||||
var dsState = state.Get<RtDepthStencilState>(MethodOffset.RtDepthStencilState);
|
var dsState = state.Get<RtDepthStencilState>(MethodOffset.RtDepthStencilState);
|
||||||
var dsSize = state.Get<Size3D>(MethodOffset.RtDepthStencilSize);
|
var dsSize = state.Get<Size3D>(MethodOffset.RtDepthStencilSize);
|
||||||
|
|
||||||
depthStencil = TextureManager.FindOrCreateTexture(dsState, dsSize, samplesInX, samplesInY);
|
depthStencil = TextureManager.FindOrCreateTexture(dsState, dsSize, samplesInX, samplesInY, sizeHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
changedScale |= TextureManager.SetRenderTargetDepthStencil(depthStencil);
|
changedScale |= TextureManager.SetRenderTargetDepthStencil(depthStencil);
|
||||||
|
|
|
@ -50,6 +50,12 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsModified { get; internal set; }
|
public bool IsModified { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when a texture has been changed size. This indicates that it may need to be
|
||||||
|
/// changed again when obtained as a sampler.
|
||||||
|
/// </summary>
|
||||||
|
public bool ChangedSize { get; internal set; }
|
||||||
|
|
||||||
private int _depth;
|
private int _depth;
|
||||||
private int _layers;
|
private int _layers;
|
||||||
private int _firstLayer;
|
private int _firstLayer;
|
||||||
|
@ -353,6 +359,8 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// <param name="depthOrLayers">The new texture depth (for 3D textures) or layers (for layered textures)</param>
|
/// <param name="depthOrLayers">The new texture depth (for 3D textures) or layers (for layered textures)</param>
|
||||||
private void RecreateStorageOrView(int width, int height, int depthOrLayers)
|
private void RecreateStorageOrView(int width, int height, int depthOrLayers)
|
||||||
{
|
{
|
||||||
|
ChangedSize = true;
|
||||||
|
|
||||||
SetInfo(new TextureInfo(
|
SetInfo(new TextureInfo(
|
||||||
Info.Address,
|
Info.Address,
|
||||||
width,
|
width,
|
||||||
|
|
|
@ -453,8 +453,9 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// <param name="copyTexture">Copy texture to find or create</param>
|
/// <param name="copyTexture">Copy texture to find or create</param>
|
||||||
/// <param name="formatInfo">Format information of the copy texture</param>
|
/// <param name="formatInfo">Format information of the copy texture</param>
|
||||||
/// <param name="preferScaling">Indicates if the texture should be scaled from the start</param>
|
/// <param name="preferScaling">Indicates if the texture should be scaled from the start</param>
|
||||||
|
/// <param name="sizeHint">A hint indicating the minimum used size for the texture</param>
|
||||||
/// <returns>The texture</returns>
|
/// <returns>The texture</returns>
|
||||||
public Texture FindOrCreateTexture(CopyTexture copyTexture, FormatInfo formatInfo, bool preferScaling = true)
|
public Texture FindOrCreateTexture(CopyTexture copyTexture, FormatInfo formatInfo, bool preferScaling = true, Size? sizeHint = null)
|
||||||
{
|
{
|
||||||
ulong address = _context.MemoryManager.Translate(copyTexture.Address.Pack());
|
ulong address = _context.MemoryManager.Translate(copyTexture.Address.Pack());
|
||||||
|
|
||||||
|
@ -500,7 +501,7 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
flags |= TextureSearchFlags.WithUpscale;
|
flags |= TextureSearchFlags.WithUpscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
Texture texture = FindOrCreateTexture(info, flags);
|
Texture texture = FindOrCreateTexture(info, flags, sizeHint);
|
||||||
|
|
||||||
texture.SynchronizeMemory();
|
texture.SynchronizeMemory();
|
||||||
|
|
||||||
|
@ -513,8 +514,9 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// <param name="colorState">Color buffer texture to find or create</param>
|
/// <param name="colorState">Color buffer texture to find or create</param>
|
||||||
/// <param name="samplesInX">Number of samples in the X direction, for MSAA</param>
|
/// <param name="samplesInX">Number of samples in the X direction, for MSAA</param>
|
||||||
/// <param name="samplesInY">Number of samples in the Y direction, for MSAA</param>
|
/// <param name="samplesInY">Number of samples in the Y direction, for MSAA</param>
|
||||||
|
/// <param name="sizeHint">A hint indicating the minimum used size for the texture</param>
|
||||||
/// <returns>The texture</returns>
|
/// <returns>The texture</returns>
|
||||||
public Texture FindOrCreateTexture(RtColorState colorState, int samplesInX, int samplesInY)
|
public Texture FindOrCreateTexture(RtColorState colorState, int samplesInX, int samplesInY, Size sizeHint)
|
||||||
{
|
{
|
||||||
ulong address = _context.MemoryManager.Translate(colorState.Address.Pack());
|
ulong address = _context.MemoryManager.Translate(colorState.Address.Pack());
|
||||||
|
|
||||||
|
@ -583,7 +585,7 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
target,
|
target,
|
||||||
formatInfo);
|
formatInfo);
|
||||||
|
|
||||||
Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale);
|
Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale, sizeHint);
|
||||||
|
|
||||||
texture.SynchronizeMemory();
|
texture.SynchronizeMemory();
|
||||||
|
|
||||||
|
@ -597,8 +599,9 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// <param name="size">Size of the depth-stencil texture</param>
|
/// <param name="size">Size of the depth-stencil texture</param>
|
||||||
/// <param name="samplesInX">Number of samples in the X direction, for MSAA</param>
|
/// <param name="samplesInX">Number of samples in the X direction, for MSAA</param>
|
||||||
/// <param name="samplesInY">Number of samples in the Y direction, for MSAA</param>
|
/// <param name="samplesInY">Number of samples in the Y direction, for MSAA</param>
|
||||||
|
/// <param name="sizeHint">A hint indicating the minimum used size for the texture</param>
|
||||||
/// <returns>The texture</returns>
|
/// <returns>The texture</returns>
|
||||||
public Texture FindOrCreateTexture(RtDepthStencilState dsState, Size3D size, int samplesInX, int samplesInY)
|
public Texture FindOrCreateTexture(RtDepthStencilState dsState, Size3D size, int samplesInX, int samplesInY, Size sizeHint)
|
||||||
{
|
{
|
||||||
ulong address = _context.MemoryManager.Translate(dsState.Address.Pack());
|
ulong address = _context.MemoryManager.Translate(dsState.Address.Pack());
|
||||||
|
|
||||||
|
@ -632,7 +635,7 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
target,
|
target,
|
||||||
formatInfo);
|
formatInfo);
|
||||||
|
|
||||||
Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale);
|
Texture texture = FindOrCreateTexture(info, TextureSearchFlags.WithUpscale, sizeHint);
|
||||||
|
|
||||||
texture.SynchronizeMemory();
|
texture.SynchronizeMemory();
|
||||||
|
|
||||||
|
@ -644,8 +647,9 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="info">Texture information of the texture to be found or created</param>
|
/// <param name="info">Texture information of the texture to be found or created</param>
|
||||||
/// <param name="flags">The texture search flags, defines texture comparison rules</param>
|
/// <param name="flags">The texture search flags, defines texture comparison rules</param>
|
||||||
|
/// <param name="sizeHint">A hint indicating the minimum used size for the texture</param>
|
||||||
/// <returns>The texture</returns>
|
/// <returns>The texture</returns>
|
||||||
public Texture FindOrCreateTexture(TextureInfo info, TextureSearchFlags flags = TextureSearchFlags.None)
|
public Texture FindOrCreateTexture(TextureInfo info, TextureSearchFlags flags = TextureSearchFlags.None, Size? sizeHint = null)
|
||||||
{
|
{
|
||||||
bool isSamplerTexture = (flags & TextureSearchFlags.ForSampler) != 0;
|
bool isSamplerTexture = (flags & TextureSearchFlags.ForSampler) != 0;
|
||||||
|
|
||||||
|
@ -678,14 +682,8 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
// deletion.
|
// deletion.
|
||||||
_cache.Lift(overlap);
|
_cache.Lift(overlap);
|
||||||
}
|
}
|
||||||
else if (!TextureCompatibility.SizeMatches(overlap.Info, info))
|
|
||||||
{
|
ChangeSizeIfNeeded(info, overlap, isSamplerTexture, sizeHint);
|
||||||
// If this is used for sampling, the size must match,
|
|
||||||
// otherwise the shader would sample garbage data.
|
|
||||||
// To fix that, we create a new texture with the correct
|
|
||||||
// size, and copy the data from the old one to the new one.
|
|
||||||
overlap.ChangeSize(info.Width, info.Height, info.DepthOrLayers);
|
|
||||||
}
|
|
||||||
|
|
||||||
overlap.SynchronizeMemory();
|
overlap.SynchronizeMemory();
|
||||||
|
|
||||||
|
@ -741,25 +739,21 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
|
|
||||||
if (overlapCompatibility == TextureViewCompatibility.Full)
|
if (overlapCompatibility == TextureViewCompatibility.Full)
|
||||||
{
|
{
|
||||||
|
TextureInfo oInfo = AdjustSizes(overlap, info, firstLevel);
|
||||||
|
|
||||||
if (!isSamplerTexture)
|
if (!isSamplerTexture)
|
||||||
{
|
{
|
||||||
info = AdjustSizes(overlap, info, firstLevel);
|
info = oInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
texture = overlap.CreateView(info, sizeInfo, firstLayer, firstLevel);
|
texture = overlap.CreateView(oInfo, sizeInfo, firstLayer, firstLevel);
|
||||||
|
|
||||||
if (overlap.IsModified)
|
if (overlap.IsModified)
|
||||||
{
|
{
|
||||||
texture.SignalModified();
|
texture.SignalModified();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The size only matters (and is only really reliable) when the
|
ChangeSizeIfNeeded(info, texture, isSamplerTexture, sizeHint);
|
||||||
// texture is used on a sampler, because otherwise the size will be
|
|
||||||
// aligned.
|
|
||||||
if (!TextureCompatibility.SizeMatches(overlap.Info, info, firstLevel) && isSamplerTexture)
|
|
||||||
{
|
|
||||||
texture.ChangeSize(info.Width, info.Height, info.DepthOrLayers);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -911,6 +905,44 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes a texture's size to match the desired size for samplers,
|
||||||
|
/// or increases a texture's size to fit the region indicated by a size hint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="info">The desired texture info</param>
|
||||||
|
/// <param name="texture">The texture to resize</param>
|
||||||
|
/// <param name="isSamplerTexture">True if the texture will be used for a sampler, false otherwise</param>
|
||||||
|
/// <param name="sizeHint">A hint indicating the minimum used size for the texture</param>
|
||||||
|
private void ChangeSizeIfNeeded(TextureInfo info, Texture texture, bool isSamplerTexture, Size? sizeHint)
|
||||||
|
{
|
||||||
|
if (isSamplerTexture)
|
||||||
|
{
|
||||||
|
// If this is used for sampling, the size must match,
|
||||||
|
// otherwise the shader would sample garbage data.
|
||||||
|
// To fix that, we create a new texture with the correct
|
||||||
|
// size, and copy the data from the old one to the new one.
|
||||||
|
|
||||||
|
if (!TextureCompatibility.SizeMatches(texture.Info, info))
|
||||||
|
{
|
||||||
|
texture.ChangeSize(info.Width, info.Height, info.DepthOrLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (sizeHint != null)
|
||||||
|
{
|
||||||
|
// A size hint indicates that data will be used within that range, at least.
|
||||||
|
// If the texture is smaller than the size hint, it must be enlarged to meet it.
|
||||||
|
// The maximum size is provided by the requested info, which generally has an aligned size.
|
||||||
|
|
||||||
|
int width = Math.Max(texture.Info.Width, Math.Min(sizeHint.Value.Width, info.Width));
|
||||||
|
int height = Math.Max(texture.Info.Height, Math.Min(sizeHint.Value.Height, info.Height));
|
||||||
|
|
||||||
|
if (texture.Info.Width != width || texture.Info.Height != height)
|
||||||
|
{
|
||||||
|
texture.ChangeSize(width, height, info.DepthOrLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tries to find an existing texture matching the given buffer copy destination. If none is found, returns null.
|
/// Tries to find an existing texture matching the given buffer copy destination. If none is found, returns null.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -67,6 +67,22 @@ namespace Ryujinx.Graphics.Gpu.Image
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (texture.ChangedSize)
|
||||||
|
{
|
||||||
|
// Texture changed size at one point - it may be a different size than the sampler expects.
|
||||||
|
// This can be triggered when the size is changed by a size hint on copy or draw, but the texture has been sampled before.
|
||||||
|
|
||||||
|
TextureDescriptor descriptor = GetDescriptor(id);
|
||||||
|
|
||||||
|
int width = descriptor.UnpackWidth();
|
||||||
|
int height = descriptor.UnpackHeight();
|
||||||
|
|
||||||
|
if (texture.Info.Width != width || texture.Info.Height != height)
|
||||||
|
{
|
||||||
|
texture.ChangeSize(width, height, texture.Info.DepthOrLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Memory is automatically synchronized on texture creation.
|
// Memory is automatically synchronized on texture creation.
|
||||||
texture.SynchronizeMemory();
|
texture.SynchronizeMemory();
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue