fix(combat): escape occupied login positions
Port retail's radius-aware placement ring so a relogging player is seated beside creatures occupying the saved location, and register the local body in the shared resolved-shadow pipeline. Route new forward movement and jump through AbortAutomaticAttack so repeat combat cancels immediately on movement. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
e671b8a5c4
commit
00fe993f6f
11 changed files with 643 additions and 16 deletions
|
|
@ -15,7 +15,8 @@ namespace AcDream.App.Combat;
|
|||
/// (0x0056ADE0), <c>StartAttackRequest</c> (0x0056C040),
|
||||
/// <c>EndAttackRequest</c> (0x0056C0E0), <c>UseTime</c> (0x0056C1F0),
|
||||
/// <c>HandleAttackDoneEvent</c> (0x0056C500), and
|
||||
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0).
|
||||
/// <c>SetRequestedAttackHeight</c> (0x0056C8F0), and
|
||||
/// <c>AbortAutomaticAttack</c> (0x0056AE90).
|
||||
/// </remarks>
|
||||
public sealed class CombatAttackController : IDisposable
|
||||
{
|
||||
|
|
@ -27,6 +28,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
private readonly CombatState _combat;
|
||||
private readonly Func<bool> _canStartAttack;
|
||||
private readonly Func<AttackHeight, float, bool> _sendAttack;
|
||||
private readonly Action _sendCancelAttack;
|
||||
private readonly Func<bool> _isDualWield;
|
||||
private readonly Func<bool> _playerReadyForAttack;
|
||||
private readonly Func<bool> _autoRepeatAttack;
|
||||
|
|
@ -48,6 +50,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
CombatState combat,
|
||||
Func<bool> canStartAttack,
|
||||
Func<AttackHeight, float, bool> sendAttack,
|
||||
Action? sendCancelAttack = null,
|
||||
Func<bool>? isDualWield = null,
|
||||
Func<bool>? playerReadyForAttack = null,
|
||||
Func<bool>? autoRepeatAttack = null,
|
||||
|
|
@ -56,6 +59,7 @@ public sealed class CombatAttackController : IDisposable
|
|||
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
|
||||
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
|
||||
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
|
||||
_sendCancelAttack = sendCancelAttack ?? (() => { });
|
||||
_isDualWield = isDualWield ?? (() => false);
|
||||
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
|
||||
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
|
||||
|
|
@ -113,6 +117,26 @@ public sealed class CombatAttackController : IDisposable
|
|||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes the semantic inputs that retail turns into a new forward-motion
|
||||
/// command. Turns and sidesteps live in separate command lists and do not
|
||||
/// call <c>HandleNewForwardMovement</c>. Jump has its own equivalent cancel
|
||||
/// in <c>ClientCombatSystem::CommenceJump</c> (0x0056AF90).
|
||||
/// </summary>
|
||||
public void HandleMovementInput(InputAction action, ActivationType activation)
|
||||
{
|
||||
if (activation != ActivationType.Press)
|
||||
return;
|
||||
|
||||
if (action is InputAction.MovementForward
|
||||
or InputAction.MovementBackup
|
||||
or InputAction.MovementRunLock
|
||||
or InputAction.MovementJump)
|
||||
{
|
||||
AbortAutomaticAttack();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDesiredPower(float power)
|
||||
{
|
||||
float value = Math.Clamp(power, 0f, 1f);
|
||||
|
|
@ -153,6 +177,30 @@ public sealed class CombatAttackController : IDisposable
|
|||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientCombatSystem::AbortAutomaticAttack</c> (0x0056AE90).
|
||||
/// A new forward-movement command enters here through
|
||||
/// <c>ACCmdInterp::HandleNewForwardMovement</c> (0x0058B1F0): notify the
|
||||
/// server, end repeat mode, and hide an in-progress combat power build.
|
||||
/// Request/pending flags are deliberately left for the normal server
|
||||
/// response, matching retail.
|
||||
/// </summary>
|
||||
public void AbortAutomaticAttack()
|
||||
{
|
||||
if (!_attackServerResponsePending
|
||||
&& !_attackRequestInProgress
|
||||
&& !_repeatAttacking)
|
||||
return;
|
||||
|
||||
_sendCancelAttack();
|
||||
_repeatAttacking = false;
|
||||
|
||||
if (_buildInProgress)
|
||||
ResetPowerBar();
|
||||
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>UseTime</c>: publishes the live build and commits a released
|
||||
/// request once the ticking level reaches the power captured on release.
|
||||
|
|
|
|||
|
|
@ -607,13 +607,14 @@ internal sealed class RemotePhysicsUpdater
|
|||
/// </summary>
|
||||
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
|
||||
{
|
||||
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
|
||||
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
|
||||
float shOffX = (shLbX - liveCenterX) * 192f;
|
||||
float shOffY = (shLbY - liveCenterY) * 192f;
|
||||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||||
entityId, rm.Body.Position, rm.Body.Orientation,
|
||||
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
|
||||
ShadowPositionSynchronizer.Sync(
|
||||
_physicsEngine.ShadowObjects,
|
||||
entityId,
|
||||
rm.Body.Position,
|
||||
rm.Body.Orientation,
|
||||
rm.CellId,
|
||||
liveCenterX,
|
||||
liveCenterY);
|
||||
rm.LastShadowSyncPos = rm.Body.Position;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/AcDream.App/Physics/ShadowPositionSynchronizer.cs
Normal file
39
src/AcDream.App/Physics/ShadowPositionSynchronizer.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Shared host adapter for retail's moved-object shadow re-registration
|
||||
/// (<c>CPhysicsObj::SetPositionInternal</c> 0x00515330). Both the local body
|
||||
/// and remote dead-reckoning bodies publish their resolved position through
|
||||
/// this one cell-offset calculation.
|
||||
/// </summary>
|
||||
internal static class ShadowPositionSynchronizer
|
||||
{
|
||||
public static void Sync(
|
||||
ShadowObjectRegistry registry,
|
||||
uint entityId,
|
||||
Vector3 position,
|
||||
Quaternion orientation,
|
||||
uint cellId,
|
||||
int liveCenterX,
|
||||
int liveCenterY)
|
||||
{
|
||||
if (cellId == 0)
|
||||
return;
|
||||
|
||||
int landblockX = (int)((cellId >> 24) & 0xFFu);
|
||||
int landblockY = (int)((cellId >> 16) & 0xFFu);
|
||||
float worldOffsetX = (landblockX - liveCenterX) * 192f;
|
||||
float worldOffsetY = (landblockY - liveCenterY) * 192f;
|
||||
registry.UpdatePosition(
|
||||
entityId,
|
||||
position,
|
||||
orientation,
|
||||
worldOffsetX,
|
||||
worldOffsetY,
|
||||
cellId,
|
||||
seedCellId: cellId);
|
||||
}
|
||||
}
|
||||
|
|
@ -858,6 +858,9 @@ public sealed class GameWindow : IDisposable
|
|||
private uint _playerServerGuid;
|
||||
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
|
||||
private MovementTruthOutbound? _lastMovementTruthOutbound;
|
||||
private (System.Numerics.Vector3 Position,
|
||||
System.Numerics.Quaternion Orientation,
|
||||
uint CellId)? _lastLocalPlayerShadow;
|
||||
|
||||
private readonly record struct MovementTruthOutbound(
|
||||
string Kind,
|
||||
|
|
@ -1988,6 +1991,7 @@ public sealed class GameWindow : IDisposable
|
|||
Combat,
|
||||
CanStartLiveCombatAttack,
|
||||
SendLiveCombatAttack,
|
||||
sendCancelAttack: () => _liveSession?.SendCancelAttack(),
|
||||
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
|
||||
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
|
||||
playerReadyForAttack: IsPlayerReadyForCombatAttack,
|
||||
|
|
@ -3594,17 +3598,16 @@ public sealed class GameWindow : IDisposable
|
|||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
|
||||
// Commit B 2026-04-29 — live-entity collision registration. The
|
||||
// local player is the simulator (its PhysicsBody is the source of
|
||||
// truth for our own movement); only remotes register as targets.
|
||||
// Commit B 2026-04-29 — live-entity collision registration.
|
||||
// The local player is the simulator (its PhysicsBody is the source of
|
||||
// truth for our own movement), but retail still registers its resolved
|
||||
// CPhysicsObj as a collision target for remote creatures. The player's
|
||||
// own transition skips this entry by LocalEntityId / OBJECTINFO::object.
|
||||
// Phantom-Setup entities (no CylSpheres / no Spheres / no Radius)
|
||||
// are deliberately skipped — retail FUN's `FindObjCollisions`
|
||||
// falls through to OK_TS for any object with no collision
|
||||
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
|
||||
if (spawn.Guid != _playerServerGuid)
|
||||
{
|
||||
RegisterLiveEntityCollision(entity, setup, spawn, origin);
|
||||
}
|
||||
RegisterLiveEntityCollision(entity, setup, spawn, origin);
|
||||
|
||||
// Phase B.2: capture the server-sent MotionTableId for our own
|
||||
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
|
||||
|
|
@ -4162,6 +4165,36 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void SyncLocalPlayerShadow(
|
||||
AcDream.Core.World.WorldEntity playerEntity,
|
||||
uint cellId,
|
||||
bool force = false)
|
||||
{
|
||||
if (!force
|
||||
&& _lastLocalPlayerShadow is { } last
|
||||
&& last.CellId == cellId
|
||||
&& System.Numerics.Vector3.DistanceSquared(
|
||||
last.Position, playerEntity.Position) <= 1e-4f
|
||||
&& MathF.Abs(System.Numerics.Quaternion.Dot(
|
||||
last.Orientation, playerEntity.Rotation)) >= 0.99999f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AcDream.App.Physics.ShadowPositionSynchronizer.Sync(
|
||||
_physicsEngine.ShadowObjects,
|
||||
playerEntity.Id,
|
||||
playerEntity.Position,
|
||||
playerEntity.Rotation,
|
||||
cellId,
|
||||
_liveCenterX,
|
||||
_liveCenterY);
|
||||
_lastLocalPlayerShadow = (
|
||||
playerEntity.Position,
|
||||
playerEntity.Rotation,
|
||||
cellId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #175: the motion table's default-state pose (the closed pose for
|
||||
/// doors) — the derivation lives in
|
||||
|
|
@ -4504,6 +4537,8 @@ public sealed class GameWindow : IDisposable
|
|||
_animatedEntities.Remove(existingEntity.Id);
|
||||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||||
if (serverGuid == _playerServerGuid)
|
||||
_lastLocalPlayerShadow = null;
|
||||
|
||||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||||
// clear using the same guid the next spawn/update would use.
|
||||
|
|
@ -8307,6 +8342,7 @@ public sealed class GameWindow : IDisposable
|
|||
pe.ParentCellId = result.CellId;
|
||||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
||||
SyncLocalPlayerShadow(pe, result.CellId);
|
||||
|
||||
// Move the player entity to its current landblock in GpuWorldState
|
||||
// so it doesn't get frustum-culled when the player walks away from
|
||||
|
|
@ -11713,6 +11749,12 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic
|
||||
// combat before the movement command enters CommandInterpreter. This
|
||||
// notification does not consume the input; the movement owner below
|
||||
// still receives it normally.
|
||||
_combatAttackController?.HandleMovementInput(action, activation);
|
||||
|
||||
// Retail attack actions consume their full transition stream: key-down
|
||||
// starts the power build and key-up commits it. Handle them before the
|
||||
// generic Press-only gate below.
|
||||
|
|
@ -13278,6 +13320,33 @@ public sealed class GameWindow : IDisposable
|
|||
var initResult = _physicsEngine.Resolve(
|
||||
playerEntity.Position, pinitCellId,
|
||||
System.Numerics.Vector3.Zero, 100f);
|
||||
|
||||
// Retail enter_world -> SetPosition(flags 0x11) runs
|
||||
// CTransition::find_placement_pos after the initial cell/environment
|
||||
// placement. That second phase is what seats a relogging character
|
||||
// beside a monster that moved onto the saved logout position. The
|
||||
// legacy Resolve above supplies the already-ported AdjustPosition +
|
||||
// floor snap; this call supplies the missing object-aware ring search.
|
||||
var (placementRadius, placementHeight) =
|
||||
GetSetupCylinder(_playerServerGuid, playerEntity);
|
||||
if (placementRadius < 0.05f)
|
||||
{
|
||||
placementRadius = 0.48f;
|
||||
placementHeight = 1.835f;
|
||||
}
|
||||
var placementResult = _physicsEngine.ResolvePlacement(
|
||||
initResult.Position,
|
||||
initResult.CellId,
|
||||
placementRadius,
|
||||
placementHeight,
|
||||
_playerController.StepUpHeight,
|
||||
_playerController.StepDownHeight,
|
||||
AcDream.Core.Physics.ObjectInfoState.IsPlayer
|
||||
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||||
playerEntity.Id);
|
||||
if (placementResult.Ok)
|
||||
initResult = placementResult;
|
||||
|
||||
_playerController.SetPosition(initResult.Position, initResult.CellId,
|
||||
CellLocalForSeed(initResult.Position, initResult.CellId));
|
||||
// #111 (2026-06-10): snap the ENTITY too — parity with the
|
||||
|
|
@ -13288,6 +13357,7 @@ public sealed class GameWindow : IDisposable
|
|||
// 2 m up against the window while physics stood on the floor).
|
||||
playerEntity.SetPosition(initResult.Position);
|
||||
playerEntity.ParentCellId = initResult.CellId;
|
||||
SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true);
|
||||
|
||||
var q = playerEntity.Rotation;
|
||||
float rawYaw = MathF.Atan2(
|
||||
|
|
|
|||
|
|
@ -1325,4 +1325,47 @@ public sealed class PhysicsEngine
|
|||
|
||||
return resolveResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs retail's radius-aware placement-ring search after the host has
|
||||
/// validated the server's cell and grounded the initial position. This is
|
||||
/// the <c>CTransition::find_placement_pos</c> half of enter-world
|
||||
/// <c>SetPosition</c>; unlike an ordinary zero-distance transition it tests
|
||||
/// object occupancy and can seat a relogging player beside a creature that
|
||||
/// now occupies the saved location.
|
||||
/// </summary>
|
||||
public ResolveResult ResolvePlacement(
|
||||
Vector3 position,
|
||||
uint cellId,
|
||||
float sphereRadius,
|
||||
float sphereHeight,
|
||||
float stepUpHeight,
|
||||
float stepDownHeight,
|
||||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||
uint movingEntityId = 0)
|
||||
{
|
||||
var transition = new Transition();
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
transition.ObjectInfo.StepDown = true;
|
||||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||||
transition.ObjectInfo.State = moverFlags;
|
||||
transition.SpherePath.InitPath(
|
||||
position, position, cellId, sphereRadius, sphereHeight);
|
||||
transition.SpherePath.InsertType = InsertType.Placement;
|
||||
|
||||
bool ok = transition.FindPlacementPos(this);
|
||||
var sp = transition.SpherePath;
|
||||
var ci = transition.CollisionInfo;
|
||||
bool onGround = ci.ContactPlaneValid
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|
||||
return new ResolveResult(
|
||||
sp.CurPos,
|
||||
sp.CurCellId != 0 ? sp.CurCellId : cellId,
|
||||
onGround,
|
||||
ci.CollisionNormalValid,
|
||||
ci.CollisionNormal,
|
||||
ok);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -391,6 +391,7 @@ public sealed class SpherePath
|
|||
public Vector3 NegCollisionNormal;
|
||||
public bool CheckWalkable;
|
||||
public InsertType InsertType = InsertType.Transition;
|
||||
public bool PlacementAllowsSliding = true;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SPHEREPATH.obstruction_ethereal</c>: set to <c>1</c> per-target
|
||||
|
|
@ -987,6 +988,122 @@ public sealed class Transition
|
|||
return transitionState == TransitionState.OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CTransition::find_placement_pos</c> (0x0050BA50).
|
||||
/// Tests the requested position first, then searches concentric rings up
|
||||
/// to four metres away for the nearest valid placement. The ring spacing
|
||||
/// and angular sampling are radius-dependent exactly as in retail.
|
||||
/// </summary>
|
||||
public bool FindPlacementPos(PhysicsEngine engine)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
|
||||
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
|
||||
CollisionInfo.SlidingNormalValid = false;
|
||||
CollisionInfo.ContactPlaneValid = false;
|
||||
CollisionInfo.ContactPlaneIsWater = false;
|
||||
|
||||
var transitionState = ValidatePlacementTransition(
|
||||
TransitionalInsert(3, engine));
|
||||
if (transitionState == TransitionState.OK)
|
||||
return true;
|
||||
|
||||
if (!sp.PlacementAllowsSliding)
|
||||
return false;
|
||||
|
||||
float adjustDistance = 4f;
|
||||
float adjustRadius = adjustDistance;
|
||||
float sphereRadius = sp.LocalSphere[0].Radius;
|
||||
bool fakeSphere = false;
|
||||
|
||||
if (sphereRadius < 0.125f)
|
||||
{
|
||||
fakeSphere = true;
|
||||
adjustRadius = 2f;
|
||||
}
|
||||
else if (sphereRadius < 0.48f)
|
||||
{
|
||||
sphereRadius = 0.48f;
|
||||
}
|
||||
|
||||
float stepCountExact = 4f / sphereRadius;
|
||||
if (fakeSphere)
|
||||
stepCountExact *= 0.5f;
|
||||
if (stepCountExact <= 1f)
|
||||
return false;
|
||||
|
||||
int stepCount = (int)MathF.Ceiling(stepCountExact);
|
||||
float distancePerStep = adjustRadius / stepCount;
|
||||
float radiansPerStep = MathF.PI * distancePerStep / sphereRadius;
|
||||
float totalDistance = 0f;
|
||||
float totalRadians = 0f;
|
||||
|
||||
for (int ring = 0; ring < stepCount; ring++)
|
||||
{
|
||||
totalDistance += distancePerStep;
|
||||
totalRadians += radiansPerStep;
|
||||
|
||||
int sampleCount = (int)MathF.Ceiling(totalRadians) * 2;
|
||||
float headingStep = 360f / sampleCount;
|
||||
|
||||
for (int sample = 0; sample < sampleCount; sample++)
|
||||
{
|
||||
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
|
||||
|
||||
// Frame::set_heading/get_vector_heading: 0 degrees is +Y,
|
||||
// 90 degrees is +X in AC's compass convention.
|
||||
float headingRadians = headingStep * sample * (MathF.PI / 180f);
|
||||
var offset = new Vector3(
|
||||
MathF.Sin(headingRadians) * totalDistance,
|
||||
MathF.Cos(headingRadians) * totalDistance,
|
||||
0f);
|
||||
sp.GlobalOffset = AdjustOffset(offset);
|
||||
|
||||
if (sp.GlobalOffset.Length() < PhysicsGlobals.EPSILON)
|
||||
continue;
|
||||
|
||||
sp.AddOffsetToCheckPos(sp.GlobalOffset);
|
||||
CollisionInfo.SlidingNormalValid = false;
|
||||
CollisionInfo.ContactPlaneValid = false;
|
||||
CollisionInfo.ContactPlaneIsWater = false;
|
||||
|
||||
transitionState = ValidatePlacementTransition(
|
||||
TransitionalInsert(3, engine));
|
||||
if (transitionState == TransitionState.OK)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private TransitionState ValidatePlacementTransition(TransitionState transitionState)
|
||||
{
|
||||
var sp = SpherePath;
|
||||
if (sp.CheckCellId == 0)
|
||||
return TransitionState.Collided;
|
||||
|
||||
if (transitionState == TransitionState.OK)
|
||||
{
|
||||
sp.CurPos = sp.CheckPos;
|
||||
sp.CurCellId = sp.CheckCellId;
|
||||
sp.CurOrientation = sp.CheckOrientation;
|
||||
for (int i = 0; i < sp.NumSphere; i++)
|
||||
{
|
||||
sp.GlobalCurrCenter[i].Origin = sp.LocalSphere[i].Origin + sp.CurPos;
|
||||
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
|
||||
}
|
||||
}
|
||||
else if (sp.PlacementAllowsSliding)
|
||||
{
|
||||
// COLLISIONINFO::init at retail 0x0050B052. Placement probes are
|
||||
// independent; a failed compass sample must not bias the next one.
|
||||
CollisionInfo = new CollisionInfo();
|
||||
}
|
||||
|
||||
return transitionState;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-step collision check
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue