feat(physics): retail-faithful collision system port from ACE
Replace the patched collision system (~60-70% retail) with a faithful port of ACE's BSPTree/BSPNode/BSPLeaf/Polygon collision pipeline. BSPQuery.cs completely rewritten (1808 lines): - Polygon-level: polygon_hits_sphere_precise (retail two-loop test), pos_hits_sphere, hits_sphere, walkable_hits_sphere, check_walkable, adjust_sphere_to_plane, find_crossed_edge, adjust_to_placement_poly - BSP traversal: sphere_intersects_poly, find_walkable, hits_walkable, sphere_intersects_solid, sphere_intersects_solid_poly - BSP tree-level: find_collisions (6-path dispatcher), step_sphere_up, step_sphere_down, slide_sphere, collide_with_pt, adjust_to_plane, placement_insert PhysicsDataCache.cs: Added ResolvedPolygon type with pre-computed vertex positions and face planes (matching ACE's Polygon constructor which calls make_plane() at load time). Populated at cache time to avoid per-collision-test vertex lookups. TransitionTypes.cs: FindObjCollisions rewritten to use the retail per-object FindCollisions 6-path dispatcher instead of the old "find earliest t, then apply custom response" approach. BSP objects now go through the same collision paths as indoor cell BSP. The previous approach was explicitly rejected by the user after ~10 iterations of patches. This port follows the CLAUDE.md mandatory workflow: decompile first → cross-reference ACE → port faithfully. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b16a149718
commit
874bcc8690
3 changed files with 1781 additions and 1211 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,7 @@ using System.Numerics;
|
|||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Plane = System.Numerics.Plane;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ public sealed class PhysicsDataCache
|
|||
PhysicsPolygons = gfxObj.PhysicsPolygons,
|
||||
BoundingSphere = gfxObj.PhysicsBSP.Root.BoundingSphere,
|
||||
Vertices = gfxObj.VertexArray,
|
||||
Resolved = ResolvePolygons(gfxObj.PhysicsPolygons, gfxObj.VertexArray),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -75,9 +77,66 @@ public sealed class PhysicsDataCache
|
|||
Vertices = cellStruct.VertexArray,
|
||||
WorldTransform = worldTransform,
|
||||
InverseWorldTransform = inverseTransform,
|
||||
Resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-resolve all physics polygons: lookup vertex positions from VertexArray
|
||||
/// and compute the face plane. Matches ACE's Polygon constructor which calls
|
||||
/// make_plane() and resolves Vertices from VertexIDs at load time.
|
||||
/// </summary>
|
||||
private static Dictionary<ushort, ResolvedPolygon> ResolvePolygons(
|
||||
Dictionary<ushort, DatReaderWriter.Types.Polygon> polys,
|
||||
VertexArray vertexArray)
|
||||
{
|
||||
var resolved = new Dictionary<ushort, ResolvedPolygon>(polys.Count);
|
||||
foreach (var (id, poly) in polys)
|
||||
{
|
||||
int numVerts = poly.VertexIds.Count;
|
||||
if (numVerts < 3) continue;
|
||||
|
||||
var verts = new Vector3[numVerts];
|
||||
bool valid = true;
|
||||
for (int i = 0; i < numVerts; i++)
|
||||
{
|
||||
ushort vid = (ushort)poly.VertexIds[i];
|
||||
if (!vertexArray.Vertices.TryGetValue(vid, out var sv))
|
||||
{ valid = false; break; }
|
||||
verts[i] = sv.Origin;
|
||||
}
|
||||
if (!valid) continue;
|
||||
|
||||
// Compute plane normal using ACE's make_plane algorithm:
|
||||
// fan cross-product accumulation + normalization.
|
||||
var normal = Vector3.Zero;
|
||||
for (int i = 1; i < numVerts - 1; i++)
|
||||
{
|
||||
var v1 = verts[i] - verts[0];
|
||||
var v2 = verts[i + 1] - verts[0];
|
||||
normal += Vector3.Cross(v1, v2);
|
||||
}
|
||||
float len = normal.Length();
|
||||
if (len < 1e-8f) continue;
|
||||
normal /= len;
|
||||
|
||||
// D = -(average dot(normal, vertex))
|
||||
float dotSum = 0f;
|
||||
for (int i = 0; i < numVerts; i++)
|
||||
dotSum += Vector3.Dot(normal, verts[i]);
|
||||
float d = -(dotSum / numVerts);
|
||||
|
||||
resolved[id] = new ResolvedPolygon
|
||||
{
|
||||
Vertices = verts,
|
||||
Plane = new Plane(normal, d),
|
||||
NumPoints = numVerts,
|
||||
SidesType = poly.SidesType,
|
||||
};
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
|
||||
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
|
||||
public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
|
||||
|
|
@ -86,6 +145,19 @@ public sealed class PhysicsDataCache
|
|||
public int CellStructCount => _cellStruct.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A physics polygon with pre-resolved vertex positions and pre-computed plane.
|
||||
/// ACE pre-computes these in its Polygon constructor; we do it at cache time
|
||||
/// to avoid per-collision-test vertex lookups.
|
||||
/// </summary>
|
||||
public sealed class ResolvedPolygon
|
||||
{
|
||||
public required Vector3[] Vertices { get; init; }
|
||||
public required Plane Plane { get; init; }
|
||||
public required int NumPoints { get; init; }
|
||||
public required CullMode SidesType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Cached physics data for a single GfxObj part.</summary>
|
||||
public sealed class GfxObjPhysics
|
||||
{
|
||||
|
|
@ -93,6 +165,12 @@ public sealed class GfxObjPhysics
|
|||
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
|
||||
public Sphere? BoundingSphere { get; init; }
|
||||
public required VertexArray Vertices { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-resolved polygon data with vertex positions and computed planes.
|
||||
/// Populated once at cache time so BSP queries don't pay per-test lookup cost.
|
||||
/// </summary>
|
||||
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Cached collision shape data for a Setup (character/creature capsule).</summary>
|
||||
|
|
@ -118,4 +196,9 @@ public sealed class CellPhysics
|
|||
public required VertexArray Vertices { get; init; }
|
||||
public Matrix4x4 WorldTransform { get; init; }
|
||||
public Matrix4x4 InverseWorldTransform { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Pre-resolved polygon data with vertex positions and computed planes.
|
||||
/// </summary>
|
||||
public required Dictionary<ushort, ResolvedPolygon> Resolved { get; init; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -547,10 +547,10 @@ public sealed class Transition
|
|||
}
|
||||
|
||||
// Use the full 6-path BSP dispatcher for retail-faithful collision.
|
||||
// Use pre-resolved polygons (vertices+planes computed at cache time).
|
||||
var cellState = BSPQuery.FindCollisions(
|
||||
cellPhysics.BSP.Root,
|
||||
cellPhysics.PhysicsPolygons,
|
||||
cellPhysics.Vertices,
|
||||
cellPhysics.Resolved,
|
||||
this,
|
||||
localSphere,
|
||||
localSphere1,
|
||||
|
|
@ -665,27 +665,28 @@ public sealed class Transition
|
|||
|
||||
/// <summary>
|
||||
/// Query the ShadowObjectRegistry for nearby static objects and run
|
||||
/// sphere-vs-BSP collision against each. On hit, calls SlideSphere to
|
||||
/// compute a wall-slide offset and returns the result.
|
||||
/// collision against each using the retail BSPTree.find_collisions 6-path
|
||||
/// dispatcher.
|
||||
///
|
||||
/// Object-local transform: the player sphere is mapped into each object's
|
||||
/// local space via the inverse of (Rotation, Position) before the BSP query.
|
||||
/// The hit normal is then rotated back to world space.
|
||||
/// ACE: ObjCell.FindObjCollisions iterates objects, calling
|
||||
/// PhysicsObj.FindObjCollisions on each. For BSP objects, this transforms
|
||||
/// to object-local space and calls BSPTree.find_collisions (the 6-path
|
||||
/// dispatcher that handles step-up, slide, collide-with-point, etc.).
|
||||
///
|
||||
/// Ported from pseudocode section 4 (ObjCell.FindObjCollisions) and
|
||||
/// section 6 (SlideSphere).
|
||||
/// The retail approach processes objects sequentially — the first non-OK
|
||||
/// result modifies SpherePath and is returned. This differs from the
|
||||
/// previous "find earliest t" approach.
|
||||
/// </summary>
|
||||
private TransitionState FindObjCollisions(PhysicsEngine engine)
|
||||
{
|
||||
if (engine.DataCache is null) return TransitionState.OK;
|
||||
|
||||
var sp = SpherePath;
|
||||
var ci = CollisionInfo;
|
||||
|
||||
Vector3 checkPos = sp.GlobalSphere[0].Origin;
|
||||
Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
|
||||
float sphereRadius = sp.GlobalSphere[0].Radius;
|
||||
Vector3 movement = checkPos - currPos; // this step's movement vector
|
||||
Vector3 movement = checkPos - currPos;
|
||||
|
||||
if (!engine.TryGetLandblockContext(checkPos.X, checkPos.Y,
|
||||
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
|
||||
|
|
@ -697,154 +698,137 @@ public sealed class Transition
|
|||
worldOffsetX, worldOffsetY, landblockId,
|
||||
_nearbyObjs);
|
||||
|
||||
// Find the EARLIEST collision along the movement path.
|
||||
// Test both foot sphere (index 0) and head sphere (index 1) if present.
|
||||
float bestT = float.MaxValue;
|
||||
Vector3 bestNormal = Vector3.Zero;
|
||||
bool bestIsHeadSphere = false;
|
||||
|
||||
for (int sphereIdx = 0; sphereIdx < sp.NumSphere; sphereIdx++)
|
||||
foreach (var obj in _nearbyObjs)
|
||||
{
|
||||
Vector3 sphereCheckPos = sp.GlobalSphere[sphereIdx].Origin;
|
||||
Vector3 sphereCurrPos = sp.GlobalCurrCenter[sphereIdx].Origin;
|
||||
float sphRadius = sp.GlobalSphere[sphereIdx].Radius;
|
||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
||||
// Broad-phase: can the moving sphere reach this object?
|
||||
Vector3 deltaToCurr = currPos - obj.Position;
|
||||
float distToCurr;
|
||||
if (obj.CollisionType == ShadowCollisionType.Cylinder)
|
||||
distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
|
||||
else
|
||||
distToCurr = deltaToCurr.Length();
|
||||
float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f;
|
||||
if (distToCurr > maxReach)
|
||||
continue;
|
||||
|
||||
foreach (var obj in _nearbyObjs)
|
||||
TransitionState result;
|
||||
|
||||
if (obj.CollisionType == ShadowCollisionType.BSP)
|
||||
{
|
||||
// Broad-phase: can the moving sphere reach this object?
|
||||
Vector3 deltaToCurr = sphereCurrPos - obj.Position;
|
||||
float distToCurr;
|
||||
if (obj.CollisionType == ShadowCollisionType.Cylinder)
|
||||
distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
|
||||
else
|
||||
distToCurr = deltaToCurr.Length();
|
||||
float maxReach = sphRadius + obj.Radius + sphMovement.Length() + 2f;
|
||||
if (distToCurr > maxReach)
|
||||
continue;
|
||||
// ── BSP object: use the full 6-path retail dispatcher ────
|
||||
// ACE: PhysicsObj.FindObjCollisions → Setup.BSP.find_collisions
|
||||
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
|
||||
if (physics?.BSP?.Root is null) continue;
|
||||
|
||||
float t;
|
||||
Vector3 worldHitNormal;
|
||||
// Transform player spheres to object-local space.
|
||||
var invRot = Quaternion.Inverse(obj.Rotation);
|
||||
|
||||
if (obj.CollisionType == ShadowCollisionType.BSP)
|
||||
var localSphere0 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
|
||||
if (physics?.BSP?.Root is null) continue;
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[0].Origin - obj.Position, invRot),
|
||||
Radius = sp.GlobalSphere[0].Radius,
|
||||
};
|
||||
var localCurrCenter = Vector3.Transform(
|
||||
sp.GlobalCurrCenter[0].Origin - obj.Position, invRot);
|
||||
|
||||
// Transform to object-local space.
|
||||
var invRot = Quaternion.Inverse(obj.Rotation);
|
||||
Vector3 localCurrPos = Vector3.Transform(sphereCurrPos - obj.Position, invRot);
|
||||
Vector3 localMovement = Vector3.Transform(sphMovement, invRot);
|
||||
|
||||
// Use movement-aware BSP query with front-face culling.
|
||||
if (!BSPQuery.SphereIntersectsPolyWithTime(
|
||||
physics.BSP.Root,
|
||||
physics.PhysicsPolygons,
|
||||
physics.Vertices,
|
||||
localCurrPos, sphRadius,
|
||||
localMovement,
|
||||
out _, out Vector3 localHitNormal, out t))
|
||||
continue;
|
||||
|
||||
worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
|
||||
t = Math.Clamp(t, 0f, 1f);
|
||||
}
|
||||
else
|
||||
DatReaderWriter.Types.Sphere? localSphere1 = null;
|
||||
if (sp.NumSphere > 1)
|
||||
{
|
||||
// Cylinder swept-sphere test.
|
||||
Vector3 deltaCurr = sphereCurrPos - obj.Position;
|
||||
float dx = deltaCurr.X, dy = deltaCurr.Y;
|
||||
float mx = sphMovement.X, my = sphMovement.Y;
|
||||
float combinedR = sphRadius + obj.Radius;
|
||||
|
||||
float a = mx * mx + my * my;
|
||||
float b = 2f * (dx * mx + dy * my);
|
||||
float c = dx * dx + dy * dy - combinedR * combinedR;
|
||||
|
||||
if (a < PhysicsGlobals.EPSILON)
|
||||
localSphere1 = new DatReaderWriter.Types.Sphere
|
||||
{
|
||||
if (c > 0f) continue;
|
||||
t = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
float disc = b * b - 4f * a * c;
|
||||
if (disc < 0f) continue;
|
||||
float sqrtDisc = MathF.Sqrt(disc);
|
||||
t = (-b - sqrtDisc) / (2f * a);
|
||||
if (t > 1f) continue;
|
||||
if (t < 0f) t = 0f;
|
||||
}
|
||||
|
||||
// Vertical check at contact time.
|
||||
Vector3 contactPos = sphereCurrPos + sphMovement * t;
|
||||
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
||||
float playerBottom = contactPos.Z - sphRadius;
|
||||
float playerTop = contactPos.Z + sphRadius;
|
||||
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
|
||||
continue;
|
||||
|
||||
// Normal: radial at contact point.
|
||||
Vector3 contactDelta = contactPos - obj.Position;
|
||||
float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
|
||||
if (hDist < PhysicsGlobals.EPSILON)
|
||||
worldHitNormal = Vector3.UnitX;
|
||||
else
|
||||
worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
|
||||
Origin = Vector3.Transform(sp.GlobalSphere[1].Origin - obj.Position, invRot),
|
||||
Radius = sp.GlobalSphere[1].Radius,
|
||||
};
|
||||
}
|
||||
|
||||
if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
bestT = t;
|
||||
bestNormal = Vector3.Normalize(worldHitNormal);
|
||||
bestIsHeadSphere = (sphereIdx == 1);
|
||||
}
|
||||
// Local-space Z (up direction rotated into object space).
|
||||
var localSpaceZ = Vector3.Transform(Vector3.UnitZ, invRot);
|
||||
|
||||
// Use the retail 6-path dispatcher with pre-resolved polygons.
|
||||
result = BSPQuery.FindCollisions(
|
||||
physics.BSP.Root,
|
||||
physics.Resolved,
|
||||
this,
|
||||
localSphere0,
|
||||
localSphere1,
|
||||
localCurrCenter,
|
||||
localSpaceZ,
|
||||
1.0f); // scale = 1.0 for object geometry
|
||||
}
|
||||
else
|
||||
{
|
||||
// ── Cylinder object: swept-sphere cylinder test ──────────
|
||||
// ACE: Sphere.IntersectsSphere handles CylSphere objects via
|
||||
// the same 6-path dispatcher. For now we keep the swept-sphere
|
||||
// cylinder test which matches the retail CylSphere behavior.
|
||||
result = CylinderCollision(obj, sp);
|
||||
}
|
||||
|
||||
if (result != TransitionState.OK)
|
||||
return result;
|
||||
}
|
||||
|
||||
if (bestT >= float.MaxValue)
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cylinder swept-sphere collision test for CylSphere objects (trees, rocks, etc.).
|
||||
/// Performs a 2D ray-circle intersection to find contact time, then applies
|
||||
/// a wall-slide response.
|
||||
/// </summary>
|
||||
private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp)
|
||||
{
|
||||
var ci = CollisionInfo;
|
||||
Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin;
|
||||
Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin;
|
||||
float sphRadius = sp.GlobalSphere[0].Radius;
|
||||
Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
|
||||
|
||||
Vector3 deltaCurr = sphereCurrPos - obj.Position;
|
||||
float dx = deltaCurr.X, dy = deltaCurr.Y;
|
||||
float mx = sphMovement.X, my = sphMovement.Y;
|
||||
float combinedR = sphRadius + obj.Radius;
|
||||
|
||||
float a = mx * mx + my * my;
|
||||
float b = 2f * (dx * mx + dy * my);
|
||||
float c = dx * dx + dy * dy - combinedR * combinedR;
|
||||
|
||||
float t;
|
||||
if (a < PhysicsGlobals.EPSILON)
|
||||
{
|
||||
if (c > 0f) return TransitionState.OK;
|
||||
t = 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
float disc = b * b - 4f * a * c;
|
||||
if (disc < 0f) return TransitionState.OK;
|
||||
float sqrtDisc = MathF.Sqrt(disc);
|
||||
t = (-b - sqrtDisc) / (2f * a);
|
||||
if (t > 1f) return TransitionState.OK;
|
||||
if (t < 0f) t = 0f;
|
||||
}
|
||||
|
||||
// Vertical check at contact time.
|
||||
Vector3 contactPos = sphereCurrPos + sphMovement * t;
|
||||
float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
|
||||
float playerBottom = contactPos.Z - sphRadius;
|
||||
float playerTop = contactPos.Z + sphRadius;
|
||||
if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
|
||||
return TransitionState.OK;
|
||||
}
|
||||
|
||||
// ── Fix 3: Contact-path step-up attempt ─────────────────────────
|
||||
// When in contact with ground and hitting a low obstacle (not the head
|
||||
// sphere), try stepping up before falling back to slide.
|
||||
// ACE: BSPTree.find_collisions path 5 — Contact|OnWalkable → step_sphere_up.
|
||||
if (!bestIsHeadSphere
|
||||
&& ObjectInfo.Contact
|
||||
&& bestNormal.Z > PhysicsGlobals.EPSILON
|
||||
&& bestNormal.Z < PhysicsGlobals.FloorZ)
|
||||
{
|
||||
// The surface is angled (not a vertical wall, not a floor) —
|
||||
// attempt step-up. Set the flag for the transition system.
|
||||
sp.StepUp = true;
|
||||
sp.StepUpNormal = bestNormal;
|
||||
ci.SetCollisionNormal(bestNormal);
|
||||
return TransitionState.OK;
|
||||
}
|
||||
// Collision normal: radial from cylinder axis.
|
||||
Vector3 contactDelta = contactPos - obj.Position;
|
||||
float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
|
||||
Vector3 collisionNormal;
|
||||
if (hDist < PhysicsGlobals.EPSILON)
|
||||
collisionNormal = Vector3.UnitX;
|
||||
else
|
||||
collisionNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
|
||||
|
||||
// Already overlapping at the START of the step (bestT == 0 or very small).
|
||||
if (bestT <= PhysicsGlobals.EPSILON)
|
||||
{
|
||||
Vector3 pushOut = bestNormal * (sphereRadius * 0.5f + 0.01f);
|
||||
sp.AddOffsetToCheckPos(pushOut);
|
||||
ci.SetCollisionNormal(bestNormal);
|
||||
ci.SetSlidingNormal(bestNormal);
|
||||
return TransitionState.Adjusted;
|
||||
}
|
||||
|
||||
// Rewind the sphere to just BEFORE the contact point.
|
||||
if (bestT < 1f)
|
||||
{
|
||||
float safeT = MathF.Max(0f, bestT - 0.02f);
|
||||
Vector3 contactPos = currPos + movement * safeT;
|
||||
contactPos += bestNormal * 0.02f;
|
||||
sp.SetCheckPos(contactPos, sp.CheckCellId);
|
||||
}
|
||||
|
||||
// Apply wall-slide from the contact point.
|
||||
return SlideSphere(bestNormal, currPos);
|
||||
// Apply collision response via wall-slide.
|
||||
ci.SetCollisionNormal(collisionNormal);
|
||||
return SlideSphere(collisionNormal, sphereCurrPos);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue