feat(render) Campaign FW3.2b-2 step 1: the production leaf adapter
Additive, nothing invokes it yet (the cutover flip is step 2): - WalkProductionLeafRenderer + RetailPViewPassExecutor.WalkLeaf: the walk driver's leaf turns over the SAME executor renderers the packed path uses today - DrawWalkSky (the per-slice sky block looped under one driver turn), DrawWalkTerrainSlice (the terrain block of DrawLandscapeSlice), per-cell shells via EnvCellRenderer, DrawWalkPunchFan (PortalDepthMaskRenderer far-Z, +ShellDrawLiftZ matching today's DrawPortalDepthWrite until FW3.3 retires it), the alpha barrier via FlushLandscapeAlphaFartherThan, and caller-supplied clear/seal actions (the renderer owns the pass scope). The cutover changes ORDER, never leaf mechanics. - Punch fans now carry the ACTIVE VIEW INDEX end to end (retail pins building_view = Render::portal_view_num @0x0059f3bf for the whole two-pass walk; the fan clips by that view's slice planes): PortalPassSink.ActiveViewIndex -> IWalkEventSink.OnPunchGeometry -> IWalkFrameLeafRenderer.DrawPunchFan. - The FW3.2b-2 rooting design is recorded in the plan (dual-compute split, LookInObject route filtered to dynamics, consumer re-pointing, gate list). Suites: full Release build 0 warnings; Walk lane green; hermetic green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
035d7b0148
commit
1b59eae428
6 changed files with 234 additions and 10 deletions
202
src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs
Normal file
202
src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering.Walk;
|
||||||
|
using AcDream.Core.Vfx;
|
||||||
|
|
||||||
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
|
/// <summary>Campaign FW3.2b-2 (step 1): the walk driver's leaf draws over
|
||||||
|
/// this executor's renderers. Same leaf mechanics as the packed slice
|
||||||
|
/// methods — the cutover changes ORDER only.</summary>
|
||||||
|
internal sealed partial class RetailPViewPassExecutor
|
||||||
|
{
|
||||||
|
/// <summary>The walk's single sky turn: retail draws GameSky once
|
||||||
|
/// inside <c>LScape::draw</c>, clipped by the active views; looping
|
||||||
|
/// today's per-slice scissor + terrain-clip block reproduces today's
|
||||||
|
/// pixels while the driver sees ONE turn.</summary>
|
||||||
|
internal void DrawWalkSky(
|
||||||
|
RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly)
|
||||||
|
{
|
||||||
|
if (!frame.RenderSky)
|
||||||
|
return;
|
||||||
|
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
|
||||||
|
for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
|
||||||
|
{
|
||||||
|
ClipViewSlice slice = slices[sliceIndex];
|
||||||
|
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
|
||||||
|
_surface.BindTerrainClip();
|
||||||
|
EnableClipDistances();
|
||||||
|
_sky?.RenderSky(
|
||||||
|
frame.Camera,
|
||||||
|
frame.CameraWorldPosition,
|
||||||
|
frame.DayFraction,
|
||||||
|
frame.ActiveDayGroup,
|
||||||
|
frame.SkyKeyframe,
|
||||||
|
frame.EnvironOverrideActive);
|
||||||
|
DisableClipDistances();
|
||||||
|
if (_particles is not null && _particleRenderer is not null)
|
||||||
|
{
|
||||||
|
_particleRenderer.Draw(
|
||||||
|
frame.Camera,
|
||||||
|
frame.CameraWorldPosition,
|
||||||
|
ParticleRenderPass.SkyPreScene);
|
||||||
|
}
|
||||||
|
if (scissor)
|
||||||
|
_surface.EndScissor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The walk's per-slice terrain turn — the terrain block of
|
||||||
|
/// <c>DrawLandscapeSlice</c> (scissor + terrain clip + slice planes +
|
||||||
|
/// NDC AABB), without the sky/entity halves the walk owns separately.</summary>
|
||||||
|
internal void DrawWalkTerrainSlice(
|
||||||
|
RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly, int sliceIndex)
|
||||||
|
{
|
||||||
|
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
|
||||||
|
if ((uint)sliceIndex >= (uint)slices.Length)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(sliceIndex), sliceIndex,
|
||||||
|
$"walk terrain turn: slice {sliceIndex} of {slices.Length}");
|
||||||
|
}
|
||||||
|
ClipViewSlice slice = slices[sliceIndex];
|
||||||
|
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
|
||||||
|
_surface.BindTerrainClip();
|
||||||
|
EnableClipDistances();
|
||||||
|
_terrainDiagnostics.Begin();
|
||||||
|
_terrain?.Draw(
|
||||||
|
frame.Camera,
|
||||||
|
frame.Frustum,
|
||||||
|
neverCullLandblockId: frame.PlayerLandblockId,
|
||||||
|
clipPlanes: slice.Planes,
|
||||||
|
ndcClipAabb: slice.NdcAabb);
|
||||||
|
_terrainDiagnostics.Complete();
|
||||||
|
DisableClipDistances();
|
||||||
|
if (scissor)
|
||||||
|
_surface.EndScissor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
|
||||||
|
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
|
||||||
|
/// clipped by the pinned view's slice planes (retail
|
||||||
|
/// <c>building_view</c> @0x0059f3bf). The +<c>ShellDrawLiftZ</c>
|
||||||
|
/// matches today's <c>DrawPortalDepthWrite</c> and retires with
|
||||||
|
/// FW3.3.</summary>
|
||||||
|
internal void DrawWalkPunchFan(
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
ClipFrameAssembly clipAssembly,
|
||||||
|
WalkPolygon worldPolygon,
|
||||||
|
int activeViewIndex)
|
||||||
|
{
|
||||||
|
if (_portalDepthMask is null)
|
||||||
|
return;
|
||||||
|
Vector3[] vertices = worldPolygon.Vertices;
|
||||||
|
if (vertices.Length < 3)
|
||||||
|
return;
|
||||||
|
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
|
||||||
|
ReadOnlySpan<Vector4> planes = (uint)activeViewIndex < (uint)slices.Length
|
||||||
|
? slices[activeViewIndex].Planes
|
||||||
|
: default;
|
||||||
|
|
||||||
|
Span<Vector3> world = stackalloc Vector3[32];
|
||||||
|
int count = Math.Min(vertices.Length, world.Length);
|
||||||
|
for (int vertex = 0; vertex < count; vertex++)
|
||||||
|
{
|
||||||
|
world[vertex] = vertices[vertex];
|
||||||
|
world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
|
||||||
|
}
|
||||||
|
_portalDepthMask.DrawDepthFan(
|
||||||
|
world[..count],
|
||||||
|
frame.ViewProjection,
|
||||||
|
planes,
|
||||||
|
forceFarZ: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Campaign FW stage FW3.2b-2 (step 1, additive): the production
|
||||||
|
/// <see cref="IWalkFrameLeafRenderer"/> over this executor's leaf renderers.
|
||||||
|
/// Constructed per frame by the FW3.2b-2 rooting (plan §FW3 "FW3.2b-2 —
|
||||||
|
/// the production rooting"); nothing invokes it until the static cutover
|
||||||
|
/// flips. Each member maps a <see cref="WalkFrameDriver"/> turn onto the
|
||||||
|
/// SAME leaf calls the packed path uses today, so the cutover changes
|
||||||
|
/// ORDER, never the leaf mechanics:
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="DrawSky"/> → the sky block of <c>DrawLandscapeSlice</c>,
|
||||||
|
/// looped over the active slices internally (retail draws GameSky once
|
||||||
|
/// inside <c>LScape::draw</c> clipped by the active views; the per-slice
|
||||||
|
/// scissor+clip here reproduces today's pixels while the driver still sees
|
||||||
|
/// ONE sky turn).</item>
|
||||||
|
/// <item><see cref="DrawTerrainSlice"/> → the terrain block of
|
||||||
|
/// <c>DrawLandscapeSlice</c> (scissor + terrain clip + slice planes).</item>
|
||||||
|
/// <item><see cref="DrawCellShell"/> → <c>EnvCellRenderer</c> opaque +
|
||||||
|
/// transparent-ordered for ONE cell (retail <c>DrawEnvCell</c>
|
||||||
|
/// @0x0059f170 draws per cell at its flood turn).</item>
|
||||||
|
/// <item><see cref="DrawPunchFan"/> → <c>PortalDepthMaskRenderer.DrawDepthFan</c>
|
||||||
|
/// with <c>forceFarZ</c>, clipped by the pinned view's slice planes
|
||||||
|
/// (retail <c>building_view</c> @0x0059f3bf). The +<c>ShellDrawLiftZ</c>
|
||||||
|
/// on the fan verts matches today's <c>DrawPortalDepthWrite</c> and
|
||||||
|
/// retires with FW3.3.</item>
|
||||||
|
/// <item><see cref="ClearInteriorDepth"/>/<see cref="DrawExitSeals"/> →
|
||||||
|
/// caller-supplied actions (the renderer owns the pass scope and the
|
||||||
|
/// root-flood seal iteration; the adapter only provides the turns).</item>
|
||||||
|
/// <item><see cref="AlphaBarrier"/> → <c>FlushLandscapeAlphaFartherThan</c>
|
||||||
|
/// (the <c>DrawBuilding</c> barrier; retail's flush-all
|
||||||
|
/// <c>FlushAlphaList(0f)</c> @0x0059f30b is the FW4 adjudication
|
||||||
|
/// candidate recorded on the driver's interface).</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
|
||||||
|
{
|
||||||
|
private readonly RetailPViewPassExecutor _passes;
|
||||||
|
private readonly RetailPViewFrameInput _frame;
|
||||||
|
private readonly ClipFrameAssembly _clipAssembly;
|
||||||
|
private readonly Action _clearInteriorDepth;
|
||||||
|
private readonly Action _drawExitSeals;
|
||||||
|
private readonly HashSet<uint> _singleCellScratch = new();
|
||||||
|
private readonly List<uint> _singleCellListScratch = new();
|
||||||
|
|
||||||
|
internal WalkProductionLeafRenderer(
|
||||||
|
RetailPViewPassExecutor passes,
|
||||||
|
RetailPViewFrameInput frame,
|
||||||
|
ClipFrameAssembly clipAssembly,
|
||||||
|
Action clearInteriorDepth,
|
||||||
|
Action drawExitSeals)
|
||||||
|
{
|
||||||
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
||||||
|
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
||||||
|
_clipAssembly = clipAssembly;
|
||||||
|
_clearInteriorDepth = clearInteriorDepth
|
||||||
|
?? throw new ArgumentNullException(nameof(clearInteriorDepth));
|
||||||
|
_drawExitSeals = drawExitSeals
|
||||||
|
?? throw new ArgumentNullException(nameof(drawExitSeals));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
|
||||||
|
|
||||||
|
public void DrawTerrainSlice(int sliceIndex) =>
|
||||||
|
_passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex);
|
||||||
|
|
||||||
|
public void DrawCellShell(uint cellId)
|
||||||
|
{
|
||||||
|
_singleCellScratch.Clear();
|
||||||
|
_singleCellScratch.Add(cellId);
|
||||||
|
_passes.DrawOpaqueCellShells(_singleCellScratch);
|
||||||
|
if (_passes.CellHasTransparentShell(cellId))
|
||||||
|
{
|
||||||
|
_singleCellListScratch.Clear();
|
||||||
|
_singleCellListScratch.Add(cellId);
|
||||||
|
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearInteriorDepth() => _clearInteriorDepth();
|
||||||
|
|
||||||
|
public void DrawExitSeals() => _drawExitSeals();
|
||||||
|
|
||||||
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
|
||||||
|
_passes.DrawWalkPunchFan(_frame, _clipAssembly, worldPolygon, activeViewIndex);
|
||||||
|
|
||||||
|
public void AlphaBarrier(float viewerDistance) =>
|
||||||
|
_passes.FlushLandscapeAlphaFartherThan(viewerDistance);
|
||||||
|
}
|
||||||
|
|
@ -114,7 +114,7 @@ internal interface IRenderFrameEntityPassExecutor
|
||||||
/// is written once; the four places it touches graphics state directly are owned
|
/// is written once; the four places it touches graphics state directly are owned
|
||||||
/// by <see cref="IWorldPassSurface"/>.
|
/// by <see cref="IWorldPassSurface"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class RetailPViewPassExecutor :
|
internal sealed partial class RetailPViewPassExecutor :
|
||||||
IRetailPViewPassExecutor,
|
IRetailPViewPassExecutor,
|
||||||
IRenderFrameEntityPassExecutor,
|
IRenderFrameEntityPassExecutor,
|
||||||
IOutdoorSceneParticleOwnerSource
|
IOutdoorSceneParticleOwnerSource
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,10 @@ public sealed class RetailFrameWalk
|
||||||
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
|
Vector3 viewpoint = ctx.ViewpointInBuilding(building);
|
||||||
for (int v = 0; v < viewCount; v++)
|
for (int v = 0; v < viewCount; v++)
|
||||||
{
|
{
|
||||||
|
// Retail pins building_view = the CURRENT view for the whole
|
||||||
|
// two-pass walk (DrawMeshInternal @0x0059f3bf) — the punch fans
|
||||||
|
// it emits clip against THAT view, so the sink carries the index.
|
||||||
|
passSink.ActiveViewIndex = v;
|
||||||
ctx.SetActiveView(activeViews, v);
|
ctx.SetActiveView(activeViews, v);
|
||||||
WalkBuildingPortals.BuildDrawPortalsOnly(
|
WalkBuildingPortals.BuildDrawPortalsOnly(
|
||||||
bsp, 1, viewpoint,
|
bsp, 1, viewpoint,
|
||||||
|
|
@ -257,6 +261,11 @@ public sealed class RetailFrameWalk
|
||||||
private sealed class PortalPassSink(WalkBuilding building, IWalkEventSink sink)
|
private sealed class PortalPassSink(WalkBuilding building, IWalkEventSink sink)
|
||||||
: WalkBuildingPortals.IWalkPortalPassSink
|
: WalkBuildingPortals.IWalkPortalPassSink
|
||||||
{
|
{
|
||||||
|
/// <summary>The view the two-pass walk is currently pinned to
|
||||||
|
/// (retail <c>building_view = Render::portal_view_num</c>
|
||||||
|
/// @0x0059f3bf) — punch fans clip against THIS view's slice.</summary>
|
||||||
|
public int ActiveViewIndex;
|
||||||
|
|
||||||
public void OnPunch(WalkPolygon polygon)
|
public void OnPunch(WalkPolygon polygon)
|
||||||
{
|
{
|
||||||
// The far-Z punch is a depth-only GPU submission (FW2's ordered
|
// The far-Z punch is a depth-only GPU submission (FW2's ordered
|
||||||
|
|
@ -264,7 +273,7 @@ public sealed class RetailFrameWalk
|
||||||
// FW3.2b-1): forward to the richer sink so a driver can flush +
|
// FW3.2b-1): forward to the richer sink so a driver can flush +
|
||||||
// draw the punch fan — building-local space, world transform is
|
// draw the punch fan — building-local space, world transform is
|
||||||
// the driver's job (WalkProductionFrameContext-style lookup).
|
// the driver's job (WalkProductionFrameContext-style lookup).
|
||||||
sink.OnPunchGeometry(building, polygon);
|
sink.OnPunchGeometry(building, polygon, ActiveViewIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnDrawCells(WalkPView pview)
|
public void OnDrawCells(WalkPView pview)
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,15 @@ public interface IWalkEventSink
|
||||||
/// stayed a no-op through FW1/FW2 for exactly that reason). Default
|
/// stayed a no-op through FW1/FW2 for exactly that reason). Default
|
||||||
/// no-op.
|
/// no-op.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void OnPunchGeometry(WalkBuilding building, WalkPolygon polygon) { }
|
/// <param name="building">The building whose portal walk emitted the
|
||||||
|
/// punch.</param>
|
||||||
|
/// <param name="polygon">The portal polygon, building-local.</param>
|
||||||
|
/// <param name="activeViewIndex">The active-view index the two-pass walk
|
||||||
|
/// is pinned to (retail <c>building_view</c> @0x0059f3bf) — the punch
|
||||||
|
/// fan clips against THIS view's slice planes in production.</param>
|
||||||
|
void OnPunchGeometry(
|
||||||
|
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
|
||||||
|
{ }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fires once per interior root, at the point <c>PView::DrawCells</c>
|
/// Fires once per interior root, at the point <c>PView::DrawCells</c>
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,11 @@ internal interface IWalkFrameLeafRenderer
|
||||||
/// the punch (a preceding cell's/building's contents — never this
|
/// the punch (a preceding cell's/building's contents — never this
|
||||||
/// building's OWN shell, which retail draws only after the whole portal
|
/// building's OWN shell, which retail draws only after the whole portal
|
||||||
/// walk completes; see <see cref="WalkFrameDriver"/>'s type doc comment)
|
/// walk completes; see <see cref="WalkFrameDriver"/>'s type doc comment)
|
||||||
/// reaches the GPU first.</summary>
|
/// reaches the GPU first. <paramref name="activeViewIndex"/> is the view
|
||||||
void DrawPunchFan(WalkPolygon worldPolygon);
|
/// the emitting two-pass walk was pinned to (retail
|
||||||
|
/// <c>building_view = Render::portal_view_num</c> @0x0059f3bf) —
|
||||||
|
/// production clips the fan by that view's slice planes.</summary>
|
||||||
|
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
|
||||||
|
|
||||||
/// <summary><c>RetailAlphaQueue.FlushFartherThan</c>'s <c>DrawBuilding</c>
|
/// <summary><c>RetailAlphaQueue.FlushFartherThan</c>'s <c>DrawBuilding</c>
|
||||||
/// barrier — retail's own call site is
|
/// barrier — retail's own call site is
|
||||||
|
|
@ -424,7 +427,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
||||||
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection);
|
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection);
|
||||||
}
|
}
|
||||||
|
|
||||||
void IWalkEventSink.OnPunchGeometry(WalkBuilding building, WalkPolygon polygon)
|
void IWalkEventSink.OnPunchGeometry(
|
||||||
|
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(building);
|
ArgumentNullException.ThrowIfNull(building);
|
||||||
ArgumentNullException.ThrowIfNull(polygon);
|
ArgumentNullException.ThrowIfNull(polygon);
|
||||||
|
|
@ -432,7 +436,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink
|
||||||
|
|
||||||
FlushIfNonEmpty();
|
FlushIfNonEmpty();
|
||||||
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
|
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
|
||||||
_leafRenderer.DrawPunchFan(TransformToWorld(polygon, worldTransform));
|
_leafRenderer.DrawPunchFan(
|
||||||
|
TransformToWorld(polygon, worldTransform), activeViewIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells)
|
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells)
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,10 @@ public sealed class WalkFrameDriverTests
|
||||||
|
|
||||||
public void DrawExitSeals() => log.Add("SEALS");
|
public void DrawExitSeals() => log.Add("SEALS");
|
||||||
|
|
||||||
public void DrawPunchFan(WalkPolygon worldPolygon)
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex)
|
||||||
{
|
{
|
||||||
Punches.Add(worldPolygon);
|
Punches.Add(worldPolygon);
|
||||||
log.Add($"PUNCH:{worldPolygon.Vertices.Length}");
|
log.Add($"PUNCH:{worldPolygon.Vertices.Length}@v{activeViewIndex}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}");
|
public void AlphaBarrier(float viewerDistance) => log.Add($"ALPHA:{viewerDistance:F2}");
|
||||||
|
|
@ -406,7 +406,7 @@ public sealed class WalkFrameDriverTests
|
||||||
driver.EndFrame();
|
driver.EndFrame();
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[] { "ALPHA:12.50", "PUNCH:4", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" },
|
new[] { "ALPHA:12.50", "PUNCH:4@v0", "SHELL:00000104", "FLUSH:1:LookInStatic", "FLUSH:1:BuildingShell" },
|
||||||
log);
|
log);
|
||||||
|
|
||||||
// The punch polygon reached the leaf renderer in WORLD space: the
|
// The punch polygon reached the leaf renderer in WORLD space: the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue