fix(ui): wait for private viewport mesh residency

This commit is contained in:
Erik 2026-08-25 19:16:53 +02:00
parent 82e4b4cb6d
commit f160f3fee1
3 changed files with 476 additions and 63 deletions

View file

@ -26,8 +26,7 @@ What does NOT go here:
## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") ## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing")
**Status:** OPEN (narrowed at the gate — intermittent first-open DELAY, not **Status:** FIXED / OWNER-ACCEPTED 2026-08-25.
a hard break; NOT gate-blocking, owner passed the Campaign AS gate with it).
**Component:** private entity viewports (examination clone, inventory **Component:** private entity viewports (examination clone, inventory
paperdoll — shared `PrivateEntityViewportRenderer`). paperdoll — shared `PrivateEntityViewportRenderer`).
**Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the **Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the
@ -46,6 +45,20 @@ extras rows at small window heights) is the authored scroll-less
clipped-list behavior AS-GF1 ruled retail-correct below, plus the clip clipped-list behavior AS-GF1 ruled retail-correct below, plus the clip
line moving with resize — owner-accepted at the gate. line moving with resize — owner-accepted at the gate.
**Fix:** `PrivateEntityViewportRenderer.EntitySlot` now uses a two-phase
mesh-residency handoff. A replacement first acquires/pins its complete mesh
ownership set, remains pending while each drawable `MeshRef` crosses the
render-thread upload barrier, then atomically replaces the active entity and
texture-owner generation. While a replacement is pending the renderer keeps
the last completed viewport texture; on first open it publishes no texture so
the authored panel art remains visible instead of exposing a black-cleared
render target. The slot also detects GfxObj-id changes made in place by the
animated appraisal/chargen paths and supersedes stale pending owners without
leaking references. Focused paperdoll, appraisal, draw-order, synthetic-owner,
and new residency tests pass 30/30; the App hermetic lane passes 6,358/6,358.
The owner then live-verified repeated inventory and monster/player assessment
opens against the local ACE test server: "Good. works."
Owner report at the Campaign AS connected gate: the animated 3-D paperdoll Owner report at the Campaign AS connected gate: the animated 3-D paperdoll
in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) in the examination window (LayoutDesc `0x2100006B` element `0x10000148`)
worked correctly at baseline `974fe88a` (praised the same session) and was worked correctly at baseline `974fe88a` (praised the same session) and was
@ -107,23 +120,15 @@ Campaign AS diff for either symptom:**
presenter at all) are byte-for-byte UNCHANGED across the whole presenter at all) are byte-for-byte UNCHANGED across the whole
`974fe88a..87e98395` window (`git log -p` for both files is empty). `974fe88a..87e98395` window (`git log -p` for both files is empty).
**Conclusion:** the trigger is one of `TryGetVisibleTarget`'s **Conclusion after live recurrence:** the temporary probe ruled out every
`CurrentObjectId` check or `TrySynchronize`'s `LiveEntityRuntime. higher-level gate: the target, clone, 34 MeshRefs, camera and nonzero texture
TryGetWorldEntity`/`MeshRefs.Count` check, in code nothing in Campaign AS handle were all healthy while the pane was visibly empty. The shared slot had
touches — meaning either a pre-existing, previously-latent condition this published the clone immediately after `IncrementRefCount`, but that operation
gate round happened to trigger, or a live/timing condition a hermetic test only schedules asynchronous preparation/upload. The private pass then cleared
cannot reproduce (no live entity, no live wire exchange). its target to black while `WbDrawDispatcher` skipped every nonresident mesh.
Residency/backlog timing explains both intermittent first-open delay and the
**Probe added this session** (temporary — delete with the real fix): same symptom across inventory and monster/player examination. The probe was
`ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1` deleted in `ddbd7e40` per the probe-dies rule; no diagnostic flag remains.
`CreatureAppraisalViewportDiagnostics` in `CreatureAppraisalPresentation.cs`
logs `[AS-GF1-PROBE] creature-appraisal viewport: <reason>` on every REASON
TRANSITION (not every frame) from both `TryGetVisibleTarget` and
`RetailCreatureAppraisalCloneFactory.TrySynchronize`. Next step: relaunch
with the flag set, examine a player, and read which one of the five
possible reasons (`no ActiveView`, `windowFrame hidden`, `viewport hidden`,
`no CurrentObjectId`, `entity not found`, `no MeshRefs`) fires — that
pinpoints the real fix.
## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load ## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load

View file

@ -99,6 +99,7 @@ internal sealed class PrivateEntityViewportRenderer :
private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned;
private int _fbW; private int _fbW;
private int _fbH; private int _fbH;
private bool _hasRenderedScene;
public PrivateEntityViewportRenderer( public PrivateEntityViewportRenderer(
IWorldPassScope scope, IWorldPassScope scope,
@ -197,6 +198,23 @@ internal sealed class PrivateEntityViewportRenderer :
/// </summary> /// </summary>
public uint Render(int width, int height) public uint Render(int width, int height)
{ {
// #443: acquiring a synthetic mesh reference only schedules CPU
// preparation/GPU upload; it does not make the mesh drawable. Keep the
// last completed private scene intact until every drawable mesh in the
// replacement has crossed that upload barrier. On first open there is
// no completed scene, so return zero and let the authored panel art
// show through instead of publishing a freshly-cleared black target.
bool mainReady = _mainSlot.PrepareForDraw();
bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true;
if (!mainReady || !backdropReady)
{
return _mainSlot.Entity is not null
&& _hasRenderedScene
&& _slot.IsAssigned
? UiTextureTableHandle.FromSlot(_slot)
: 0u;
}
WorldEntity? entity = _mainSlot.Entity; WorldEntity? entity = _mainSlot.Entity;
if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0)
return 0u; return 0u;
@ -256,6 +274,7 @@ internal sealed class PrivateEntityViewportRenderer :
neverCullLandblockId: PrivateLandblockId, neverCullLandblockId: PrivateLandblockId,
visibleCellIds: null, visibleCellIds: null,
animatedEntityIds: _animatedIds); animatedEntityIds: _animatedIds);
_hasRenderedScene = true;
return UiTextureTableHandle.FromSlot(_slot); return UiTextureTableHandle.FromSlot(_slot);
} }
@ -367,6 +386,7 @@ internal sealed class PrivateEntityViewportRenderer :
_target = null; _target = null;
_fbW = 0; _fbW = 0;
_fbH = 0; _fbH = 0;
_hasRenderedScene = false;
} }
public void Dispose() public void Dispose()
@ -405,31 +425,88 @@ internal sealed class PrivateEntityViewportRenderer :
} }
} }
private static IEnumerable<ulong> CollectMeshIds(WorldEntity entity)
{
for (int i = 0; i < entity.MeshRefs.Count; i++)
yield return entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
yield return entity.PartOverrides[i].GfxObjId;
}
/// <summary> /// <summary>
/// One private entity's own mesh-reference/texture-owner lifetime, /// One private entity's mesh-reference/texture-owner lifetime, independent
/// independent of any other slot on the same renderer. Factored out at /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh
/// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets /// references while preparation/upload runs, but does not replace the
/// the EXACT SAME acquire/replace/retire behavior the main entity already /// drawable entity until all of its actual <see cref="WorldEntity.MeshRefs"/>
/// had — a single-owner class shared by both slots rather than a second, /// are resident. Kept internal so #443's lifetime/readiness behavior can
/// hand-duplicated copy of <see cref="PrivateEntityViewportRenderer.SetEntity"/>'s /// be pinned without constructing a live GPU device.
/// pre-Batch-D body.
/// </summary> /// </summary>
private sealed class EntitySlot internal sealed class EntitySlot
{ {
private sealed class MeshSnapshot
{
private readonly ulong[] _ownedIds;
private readonly int _drawMeshCount;
private MeshSnapshot(ulong[] ownedIds, int drawMeshCount)
{
_ownedIds = ownedIds;
_drawMeshCount = drawMeshCount;
}
public IReadOnlyList<ulong> OwnedIds => _ownedIds;
public static MeshSnapshot Capture(WorldEntity entity)
{
int drawMeshCount = entity.MeshRefs.Count;
var ids = new ulong[drawMeshCount + entity.PartOverrides.Count];
for (int i = 0; i < drawMeshCount; i++)
ids[i] = entity.MeshRefs[i].GfxObjId;
for (int i = 0; i < entity.PartOverrides.Count; i++)
ids[drawMeshCount + i] = entity.PartOverrides[i].GfxObjId;
return new MeshSnapshot(ids, drawMeshCount);
}
/// <summary>
/// Residency identity deliberately excludes part transforms,
/// palette ranges and surface overrides: changing those does not
/// require another mesh upload. A different entity instance still
/// stages a replacement so its fixed texture owner is refreshed.
/// </summary>
public bool Matches(WorldEntity entity)
{
if (entity.MeshRefs.Count != _drawMeshCount
|| entity.PartOverrides.Count != _ownedIds.Length - _drawMeshCount)
{
return false;
}
for (int i = 0; i < _drawMeshCount; i++)
if (entity.MeshRefs[i].GfxObjId != _ownedIds[i])
return false;
for (int i = 0; i < entity.PartOverrides.Count; i++)
if (entity.PartOverrides[i].GfxObjId != _ownedIds[_drawMeshCount + i])
return false;
return true;
}
public bool AreDrawMeshesReady(IWbMeshAdapter adapter)
{
for (int i = 0; i < _drawMeshCount; i++)
{
ulong id = _ownedIds[i];
if (id == 0u || !adapter.IsRenderDataReady(id))
return false;
}
return true;
}
}
private sealed record PendingEntity(
WorldEntity Entity,
MeshSnapshot Snapshot,
SyntheticEntityMeshReferenceOwner MeshReferences);
private readonly IWbMeshAdapter _meshAdapter; private readonly IWbMeshAdapter _meshAdapter;
private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly FixedEntityTextureOwnerLease _textureOwnerLease;
private readonly string _diagnosticName; private readonly string _diagnosticName;
private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = []; private readonly List<SyntheticEntityMeshReferenceOwner> _retiringMeshReferences = [];
private SyntheticEntityMeshReferenceOwner? _meshReferences; private SyntheticEntityMeshReferenceOwner? _meshReferences;
private MeshSnapshot? _meshSnapshot;
private PendingEntity? _pending;
public EntitySlot( public EntitySlot(
IWbMeshAdapter meshAdapter, IWbMeshAdapter meshAdapter,
@ -444,75 +521,181 @@ internal sealed class PrivateEntityViewportRenderer :
public WorldEntity? Entity { get; private set; } public WorldEntity? Entity { get; private set; }
internal bool HasPending => _pending is not null;
public void Set(WorldEntity? entity) public void Set(WorldEntity? entity)
{ {
ReleaseRetiringMeshReferences(); ReleaseRetiringMeshReferences();
if (ReferenceEquals(Entity, entity)) if (entity is null)
{
Clear();
return;
}
// The common animated appraisal path reuses one clone object. Let
// PrepareForDraw perform its allocation-free mesh-id comparison;
// if no candidate is pending, reference equality is enough here.
if (ReferenceEquals(Entity, entity) && _pending is null)
return; return;
SyntheticEntityMeshReferenceOwner? replacement = null; if (ReferenceEquals(Entity, entity)
if (entity is not null) && _meshSnapshot?.Matches(entity) == true)
{ {
replacement = new SyntheticEntityMeshReferenceOwner( ReleasePending();
_meshAdapter, return;
CollectMeshIds(entity));
replacement.Acquire();
} }
SyntheticEntityMeshReferenceOwner? previous = _meshReferences; if (_pending is { } pending
&& ReferenceEquals(pending.Entity, entity)
&& pending.Snapshot.Matches(entity))
{
return;
}
Stage(entity);
}
/// <summary>
/// Refreshes an in-place MeshRefs mutation, re-arms missing uploads,
/// and atomically promotes a fully drawable candidate. False tells the
/// renderer to preserve its last completed render target this frame.
/// </summary>
public bool PrepareForDraw()
{
ReleaseRetiringMeshReferences();
if (_pending is { } pending)
{
if (!pending.Snapshot.Matches(pending.Entity))
Stage(pending.Entity);
}
else if (Entity is { } current
&& _meshSnapshot?.Matches(current) != true)
{
// Chargen animation and appraisal synchronization both mutate
// a retained WorldEntity in place. Detect a changed GfxObj set
// here even when the caller did not issue another Set call.
Stage(current);
}
pending = _pending;
if (pending is null)
return true;
if (!pending.Snapshot.AreDrawMeshesReady(_meshAdapter))
return false;
PromotePending(pending);
return true;
}
private void Stage(WorldEntity entity)
{
MeshSnapshot snapshot = MeshSnapshot.Capture(entity);
var replacement = new SyntheticEntityMeshReferenceOwner(
_meshAdapter,
snapshot.OwnedIds);
try try
{ {
_textureOwnerLease.Replace(entity is not null); replacement.Acquire();
} }
catch (Exception textureFailure) catch (Exception acquisitionFailure)
{ {
if (replacement is null)
throw;
try try
{ {
replacement.Dispose(); replacement.Dispose();
} }
catch (Exception rollbackFailure) catch (Exception rollbackFailure)
{ {
_retiringMeshReferences.Add(replacement);
throw new AggregateException( throw new AggregateException(
$"The {_diagnosticName} texture-owner replacement failed " $"The {_diagnosticName} candidate mesh acquisition failed "
+ "and the replacement mesh-owner rollback did not converge.", + "and its rollback did not converge.",
textureFailure, acquisitionFailure,
rollbackFailure); rollbackFailure);
} }
System.Runtime.ExceptionServices.ExceptionDispatchInfo System.Runtime.ExceptionServices.ExceptionDispatchInfo
.Capture(textureFailure) .Capture(acquisitionFailure)
.Throw(); .Throw();
throw new InvalidOperationException("Unreachable exception dispatch path.");
} }
_meshReferences = replacement; PendingEntity? previous = _pending;
Entity = entity; _pending = new PendingEntity(entity, snapshot, replacement);
if (previous is not null)
Retire(previous.MeshReferences);
}
private void PromotePending(PendingEntity pending)
{
// Release the fixed texture owner's prior composites only at the
// same atomic edge that publishes the new mesh set. If release
// fails, the candidate remains pending and can retry intact.
_textureOwnerLease.Replace(hasReplacement: true);
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
_meshReferences = pending.MeshReferences;
_meshSnapshot = pending.Snapshot;
Entity = pending.Entity;
_pending = null;
if (previous is not null) if (previous is not null)
Retire(previous);
}
private void Clear()
{
if (Entity is null && _pending is null)
return;
ReleasePending();
_textureOwnerLease.Replace(hasReplacement: false);
SyntheticEntityMeshReferenceOwner? previous = _meshReferences;
_meshReferences = null;
_meshSnapshot = null;
Entity = null;
if (previous is not null)
Retire(previous);
}
private void ReleasePending()
{
PendingEntity? pending = _pending;
if (pending is null)
return;
_pending = null;
Retire(pending.MeshReferences);
}
private void Retire(SyntheticEntityMeshReferenceOwner owner)
{
try
{ {
try owner.Dispose();
{ }
previous.Dispose(); catch
} {
catch _retiringMeshReferences.Add(owner);
{ throw;
_retiringMeshReferences.Add(previous);
throw;
}
} }
} }
public void Dispose() public void Dispose()
{ {
Entity = null; Entity = null;
_meshSnapshot = null;
if (_meshReferences is { } current) if (_meshReferences is { } current)
{ {
_meshReferences = null; _meshReferences = null;
_retiringMeshReferences.Add(current); _retiringMeshReferences.Add(current);
} }
if (_pending is { } pending)
{
_pending = null;
_retiringMeshReferences.Add(pending.MeshReferences);
}
List<Exception>? failures = null; List<Exception>? failures = null;
try try

View file

@ -0,0 +1,225 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #443: private paperdoll/examination entities must cross the mesh-upload
/// barrier before the shared viewport publishes them. These tests exercise
/// the renderer's slot directly, without requiring a GPU device.
/// </summary>
public sealed class PrivateEntityViewportRendererResidencyTests
{
[Fact]
public void FirstEntityIsNotPublishedUntilEveryDrawableMeshIsReady()
{
const ulong first = 0x0100_0001u;
const ulong second = 0x0100_0002u;
var adapter = new RecordingMeshAdapter { ReadyIds = { first } };
var textures = new RecordingTextureLifetime();
var slot = CreateSlot(adapter, textures);
WorldEntity entity = Entity(first, second);
slot.Set(entity);
Assert.True(slot.HasPending);
Assert.Null(slot.Entity);
Assert.False(slot.PrepareForDraw());
Assert.Null(slot.Entity);
Assert.Equal(1, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
adapter.ReadyIds.Add(second);
Assert.True(slot.PrepareForDraw());
Assert.False(slot.HasPending);
Assert.Same(entity, slot.Entity);
slot.Dispose();
Assert.Equal(0, adapter.TotalReferences);
Assert.Equal(1, textures.ReleaseCount);
}
[Fact]
public void ReplacementKeepsActiveEntityUntilCandidateIsReady()
{
const ulong first = 0x0100_0011u;
const ulong second = 0x0100_0012u;
var adapter = new RecordingMeshAdapter { ReadyIds = { first } };
var textures = new RecordingTextureLifetime();
var slot = CreateSlot(adapter, textures);
WorldEntity active = Entity(first);
WorldEntity candidate = Entity(second);
slot.Set(active);
Assert.True(slot.PrepareForDraw());
slot.Set(candidate);
Assert.True(slot.HasPending);
Assert.Same(active, slot.Entity);
Assert.False(slot.PrepareForDraw());
Assert.Same(active, slot.Entity);
Assert.Equal(1, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
adapter.ReadyIds.Add(second);
Assert.True(slot.PrepareForDraw());
Assert.Same(candidate, slot.Entity);
Assert.Equal(0, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
Assert.Equal(1, textures.ReleaseCount);
slot.Dispose();
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void InPlaceMeshChangeIsDetectedAtTheDrawBarrier()
{
const ulong first = 0x0100_0021u;
const ulong second = 0x0100_0022u;
var adapter = new RecordingMeshAdapter { ReadyIds = { first } };
var slot = CreateSlot(adapter, new RecordingTextureLifetime());
WorldEntity entity = Entity(first);
slot.Set(entity);
Assert.True(slot.PrepareForDraw());
// Appraisal synchronization and chargen animation can mutate the same
// retained clone without calling Set again.
entity.MeshRefs = [new MeshRef((uint)second, Matrix4x4.Identity)];
Assert.False(slot.PrepareForDraw());
Assert.True(slot.HasPending);
Assert.Same(entity, slot.Entity);
Assert.Equal(1, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
adapter.ReadyIds.Add(second);
Assert.True(slot.PrepareForDraw());
Assert.False(slot.HasPending);
Assert.Equal(0, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
slot.Dispose();
}
[Fact]
public void NewerCandidateReleasesSupersededPendingOwner()
{
const ulong first = 0x0100_0031u;
const ulong second = 0x0100_0032u;
var adapter = new RecordingMeshAdapter();
var slot = CreateSlot(adapter, new RecordingTextureLifetime());
slot.Set(Entity(first));
slot.Set(Entity(second));
Assert.True(slot.HasPending);
Assert.Equal(0, adapter.ReferenceCount(first));
Assert.Equal(1, adapter.ReferenceCount(second));
slot.Dispose();
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void UnresolvedPartOverrideDoesNotBlockResolvedDrawableMeshes()
{
const ulong drawable = 0x0100_0041u;
const ulong overrideId = 0x0100_0042u;
var adapter = new RecordingMeshAdapter { ReadyIds = { drawable } };
var slot = CreateSlot(adapter, new RecordingTextureLifetime());
WorldEntity entity = Entity(
[drawable],
[new PartOverride(3, (uint)overrideId)]);
slot.Set(entity);
Assert.True(slot.PrepareForDraw());
Assert.Same(entity, slot.Entity);
Assert.Equal(1, adapter.ReferenceCount(overrideId));
slot.Dispose();
Assert.Equal(0, adapter.TotalReferences);
}
[Fact]
public void ClearReleasesBothActiveAndPendingOwners()
{
const ulong first = 0x0100_0051u;
const ulong second = 0x0100_0052u;
var adapter = new RecordingMeshAdapter { ReadyIds = { first } };
var textures = new RecordingTextureLifetime();
var slot = CreateSlot(adapter, textures);
slot.Set(Entity(first));
Assert.True(slot.PrepareForDraw());
slot.Set(Entity(second));
slot.Set(null);
Assert.Null(slot.Entity);
Assert.False(slot.HasPending);
Assert.Equal(0, adapter.TotalReferences);
Assert.Equal(1, textures.ReleaseCount);
slot.Dispose();
Assert.Equal(1, textures.ReleaseCount);
}
private static PrivateEntityViewportRenderer.EntitySlot CreateSlot(
IWbMeshAdapter adapter,
IEntityTextureLifetime textures) =>
new(adapter, textures, ownerLocalId: 0xDA11_D012u, "#443 test viewport");
private static WorldEntity Entity(params ulong[] meshIds) =>
Entity(meshIds, []);
private static WorldEntity Entity(
IReadOnlyList<ulong> meshIds,
IReadOnlyList<PartOverride> partOverrides) => new()
{
Id = 0xDA11_D012u,
ServerGuid = 0xDA11_D011u,
SourceGfxObjOrSetupId = 0x0200_0001u,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = meshIds
.Select(static id => new MeshRef((uint)id, Matrix4x4.Identity))
.ToArray(),
PartOverrides = partOverrides,
};
private sealed class RecordingTextureLifetime : IEntityTextureLifetime
{
public int ReleaseCount { get; private set; }
public void ReleaseOwner(uint localEntityId) => ReleaseCount++;
}
private sealed class RecordingMeshAdapter : IWbMeshAdapter
{
private readonly Dictionary<ulong, int> _references = [];
public HashSet<ulong> ReadyIds { get; } = [];
public int TotalReferences => _references.Values.Sum();
public int ReferenceCount(ulong id) => _references.GetValueOrDefault(id);
public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id);
public void IncrementRefCount(ulong id) =>
_references[id] = ReferenceCount(id) + 1;
public void DecrementRefCount(ulong id)
{
int current = ReferenceCount(id);
if (current <= 0)
throw new InvalidOperationException("reference underflow");
_references[id] = current - 1;
}
}
}