port #181: retail viewer step subdivision (calc_num_steps 0x0050a0b0) - radius-anchored steps + remainder final step + viewer-exempt small-offset abort
The user's retail axiom (camera rock steady pressed into walls) vs our measured wall-press wander (~0.5mm/frame limit cycle, headless pin Issue181WallPressEquilibriumTests) sent us back to the decomp. Ghidra (clean, vs the BN x87 mush): retail VIEWERS subdivide the sweep into EXACTLY radius-length steps anchored at the start (offsetPerStep = offset*r/len, numSteps = floor(len/r)+1) with the final step recomputed mid-loop as the exact remainder (find_transitional_position 0x0050bdf0), and the negligible-offset abort is NON-viewer-only. Ours used ceil equal-slices for everything and aborted viewers too. Ported faithfully (pseudocode docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md); non-viewer stepping already matched (TRANSITIONAL_PERCENT_OF_RADIUS=1.0). Measurement: the wall-press limit cycle is UNCHANGED by the port (537.8um avg; a bit-exact 12-frame cycle: ~130um/frame inward creep x11 then a 2.6mm snap). With adjust_to_plane + adjust_sphere_to_poly now also Ghidra-verified faithful, the residual mm cycle is likely retail-class plateau physics - invisible at retail's 60fps vsync, tear-interleaved into visible stripes at our ~1500fps unsynced. The decisive user test: VSync ON (Settings/F11). Fallback discriminator: cdb-trace retail's viewer at a wall press. Suites green (Core 2600 / App 733 / UI 425 / Net 385). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d4ff80cb2b
commit
3f34bca06f
3 changed files with 318 additions and 16 deletions
|
|
@ -0,0 +1,75 @@
|
||||||
|
# `CTransition::calc_num_steps` + the viewer step loop — pseudocode (#181 equilibrium fix)
|
||||||
|
|
||||||
|
Source: Ghidra decompile (patchmem project, PDB-named) of `calc_num_steps`
|
||||||
|
(`0x0050a0b0`) and `find_transitional_position` (`0x0050bdf0`), read 2026-07-06
|
||||||
|
after the user's retail axiom ("retail camera is rock steady" pressed into
|
||||||
|
walls) contradicted acdream's measured wall-press wander (~0.5 mm/frame
|
||||||
|
forever, headless pin `Issue181WallPressEquilibriumTests`). The BN pseudo-C of
|
||||||
|
the same functions is x87 mush; Ghidra's is clean.
|
||||||
|
|
||||||
|
## `calc_num_steps` — 0x0050a0b0
|
||||||
|
|
||||||
|
```
|
||||||
|
calc_num_steps(this, out offset, out offsetPerStep, out numSteps):
|
||||||
|
if begin_pos == null: offset = 0; offsetPerStep = 0; numSteps = 1; return
|
||||||
|
offset = get_offset(begin_pos, end_pos)
|
||||||
|
r = local_sphere.radius
|
||||||
|
len = |offset|
|
||||||
|
|
||||||
|
if object_info.state & 4: # VIEWER (bit 2 of the 0x5c camera flags)
|
||||||
|
if len > F_EPSILON: # 2e-4
|
||||||
|
offsetPerStep = offset * (r / len) # EXACTLY radius-length steps, start-anchored
|
||||||
|
numSteps = floor(len / r) + 1 # the end lands INSIDE the last step
|
||||||
|
else:
|
||||||
|
offsetPerStep = 0; numSteps = 0 # zero-length path → the no-step success path
|
||||||
|
return
|
||||||
|
|
||||||
|
# non-viewer: equal slices of ~radius (TRANSITIONAL_PERCENT_OF_RADIUS = 1.0, 0x007c6874)
|
||||||
|
steps = len / (TRANSITIONAL_PERCENT_OF_RADIUS * r)
|
||||||
|
if steps > 1:
|
||||||
|
numSteps = ceil(steps); offsetPerStep = offset / numSteps
|
||||||
|
elif offset == 0:
|
||||||
|
offsetPerStep = 0; numSteps = 0
|
||||||
|
else:
|
||||||
|
offsetPerStep = offset; numSteps = 1 # short move → one whole step
|
||||||
|
```
|
||||||
|
|
||||||
|
## The step loop's viewer handling — `find_transitional_position` 0x0050bdf0
|
||||||
|
|
||||||
|
```
|
||||||
|
for i in 0 .. numSteps-1:
|
||||||
|
# VIEWER LAST-STEP REMAINDER: the final step covers exactly what's left,
|
||||||
|
# so the swept path ends exactly at end_pos (no overshoot):
|
||||||
|
if (state & 4) and i == numSteps-1 and len > F_EPSILON:
|
||||||
|
offsetPerStep = offset * ((len - (numSteps-1)*r) / len)
|
||||||
|
|
||||||
|
global_offset = adjust_offset(offsetPerStep)
|
||||||
|
|
||||||
|
# The negligible-offset abort is NON-VIEWER-ONLY — a pressed camera keeps
|
||||||
|
# stepping through sub-epsilon adjusted offsets:
|
||||||
|
if (state & 4) == 0 and |global_offset|² < F_EPSILON²: break
|
||||||
|
|
||||||
|
... check_pos += global_offset; transitional_insert(3); validate ...
|
||||||
|
if collision_normal_valid and (state & 8): break # PathClipped first-hit stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why this stabilizes the wall-press equilibrium (#181)
|
||||||
|
|
||||||
|
acdream's pre-fix stepping for ALL movers was `numSteps = ceil(len/r)` equal
|
||||||
|
slices. The camera's convergence loop (sought = lerp(viewer→desired) → sweep →
|
||||||
|
viewer) perturbs `len` by mm every frame:
|
||||||
|
|
||||||
|
- equal slices: every step boundary shifts with `len`, and at `len` near an
|
||||||
|
exact multiple of r (the measured press pose: 1.5 m = 5 × 0.3 m) the step
|
||||||
|
COUNT flaps ceil-wise, teleporting the colliding step's window ~5 cm — the
|
||||||
|
clip's committed point jumps mm-scale, and the loop orbits a limit cycle
|
||||||
|
instead of a fixed point (the measured ~0.5 mm/frame wander → the #181
|
||||||
|
flicker excitation);
|
||||||
|
- retail viewer grid: the first `floor(len/r)` steps are constant radius-length
|
||||||
|
increments anchored at the pivot — invariant under mm target drift — and only
|
||||||
|
the remainder step breathes. The colliding step's window is stable, the clip
|
||||||
|
result is stable, the loop reaches retail's fixed point ("rock steady").
|
||||||
|
|
||||||
|
Port sites: `Transition.FindTransitionalPosition` (TransitionTypes.cs) — the
|
||||||
|
subdivision block + the per-step loop (last-step remainder + the abort gate).
|
||||||
|
Non-viewer behavior unchanged (already matches).
|
||||||
|
|
@ -770,8 +770,19 @@ public sealed class Transition
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Step subdivision: each sub-step travels at most one sphere radius
|
// Step subdivision (retail CTransition::calc_num_steps 0x0050a0b0,
|
||||||
// to prevent tunnelling through thin surfaces.
|
// pseudocode docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md).
|
||||||
|
// Two shapes:
|
||||||
|
// - VIEWER (the camera sweep, state & 4): steps of EXACTLY one radius,
|
||||||
|
// anchored at the start — numSteps = floor(len/r) + 1, with the final
|
||||||
|
// step recomputed in the loop as the exact remainder. The anchored
|
||||||
|
// grid is invariant under mm-scale target drift, which is what makes
|
||||||
|
// retail's wall-pressed camera a fixed point ("rock steady"); the
|
||||||
|
// pre-#181 equal-slice grid reshuffled every boundary per frame and
|
||||||
|
// the camera orbited a ~0.5 mm/frame limit cycle instead
|
||||||
|
// (Issue181WallPressEquilibriumTests).
|
||||||
|
// - non-viewer: equal slices of ~one radius (TRANSITIONAL_PERCENT_OF_
|
||||||
|
// RADIUS = 1.0 at 0x007c6874) — unchanged, matches retail.
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
Vector3 offset = sp.EndPos - sp.BeginPos;
|
Vector3 offset = sp.EndPos - sp.BeginPos;
|
||||||
float dist = offset.Length();
|
float dist = offset.Length();
|
||||||
|
|
@ -781,11 +792,26 @@ public sealed class Transition
|
||||||
if (radius <= PhysicsGlobals.EPSILON)
|
if (radius <= PhysicsGlobals.EPSILON)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
float step = dist / radius;
|
|
||||||
|
|
||||||
int numSteps;
|
int numSteps;
|
||||||
Vector3 offsetPerStep;
|
Vector3 offsetPerStep;
|
||||||
|
|
||||||
|
if (ObjectInfo.IsViewer)
|
||||||
|
{
|
||||||
|
if (dist > PhysicsGlobals.EPSILON)
|
||||||
|
{
|
||||||
|
offsetPerStep = offset * (radius / dist); // radius-length steps
|
||||||
|
numSteps = (int)MathF.Floor(dist / radius) + 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
numSteps = 0;
|
||||||
|
offsetPerStep = Vector3.Zero;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
float step = dist / radius;
|
||||||
|
|
||||||
if (step > 1.0f)
|
if (step > 1.0f)
|
||||||
{
|
{
|
||||||
numSteps = (int)MathF.Ceiling(step);
|
numSteps = (int)MathF.Ceiling(step);
|
||||||
|
|
@ -801,6 +827,7 @@ public sealed class Transition
|
||||||
numSteps = 0;
|
numSteps = 0;
|
||||||
offsetPerStep = Vector3.Zero;
|
offsetPerStep = Vector3.Zero;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Retail safety cap (30 steps). Viewer/sight objects bypass it, matching
|
// Retail safety cap (30 steps). Viewer/sight objects bypass it, matching
|
||||||
// retail: CTransition::find_transitional_position (acclient_2013_pseudo_c.txt
|
// retail: CTransition::find_transitional_position (acclient_2013_pseudo_c.txt
|
||||||
|
|
@ -843,6 +870,13 @@ public sealed class Transition
|
||||||
|
|
||||||
for (int i = 0; i < numSteps; i++)
|
for (int i = 0; i < numSteps; i++)
|
||||||
{
|
{
|
||||||
|
// Viewer last-step remainder (retail find_transitional_position
|
||||||
|
// 0x0050bdf0: on i == numSteps−1 for state&4, the step offset is
|
||||||
|
// recomputed as offset · (len − (numSteps−1)·r)/len — the sweep
|
||||||
|
// ends exactly at EndPos, never overshooting the anchored grid).
|
||||||
|
if (ObjectInfo.IsViewer && i == numSteps - 1 && dist > PhysicsGlobals.EPSILON)
|
||||||
|
offsetPerStep = offset * ((dist - (numSteps - 1) * radius) / dist);
|
||||||
|
|
||||||
Vector3 requestedOffset = offsetPerStep;
|
Vector3 requestedOffset = offsetPerStep;
|
||||||
|
|
||||||
// Per ACE order: AdjustOffset FIRST (uses state from previous step),
|
// Per ACE order: AdjustOffset FIRST (uses state from previous step),
|
||||||
|
|
@ -859,8 +893,13 @@ public sealed class Transition
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort if adjusted offset is negligible (stuck against a wall
|
// Abort if adjusted offset is negligible (stuck against a wall
|
||||||
// with no slide tangent available).
|
// with no slide tangent available). NON-VIEWER-ONLY per retail
|
||||||
if (sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
// (find_transitional_position 0x0050bdf0: `(state & 4) == 0 &&
|
||||||
|
// |global_offset|² < F_EPSILON²`) — a pressed camera keeps
|
||||||
|
// stepping through sub-epsilon adjusted offsets so its remainder
|
||||||
|
// step still lands exactly on the sought (#181 equilibrium).
|
||||||
|
if (!ObjectInfo.IsViewer
|
||||||
|
&& sp.GlobalOffset.LengthSquared() < PhysicsGlobals.EpsilonSq)
|
||||||
{
|
{
|
||||||
if (stepWalkProbe)
|
if (stepWalkProbe)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using DatEnvCell = DatReaderWriter.DBObjs.EnvCell;
|
||||||
|
using DatEnvironment = DatReaderWriter.DBObjs.Environment;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #181 excitation, isolated headlessly — the WALL-PRESS equilibrium. Live
|
||||||
|
/// evidence: a camera pressed into corridor walls/openings never reaches a
|
||||||
|
/// fixed point (sought steps α·gap into the wall per frame; the sweep clips it
|
||||||
|
/// back within adjust_to_plane's parametric 0.02 window) → the published eye
|
||||||
|
/// wanders ~1 mm/frame, and when the wander straddles a cell boundary the
|
||||||
|
/// VIEWER CELL flaps (launch-181-pressed.log: viewer≠player on 85.5% of
|
||||||
|
/// frames, one-frame A→B→A root flips) — each flip re-roots the whole
|
||||||
|
/// visibility frame (the #176/#181 flicker).
|
||||||
|
///
|
||||||
|
/// This test runs the REAL RetailChaseCamera + the REAL
|
||||||
|
/// PhysicsCameraCollisionProbe against the REAL Facility Hub BSP with a
|
||||||
|
/// static player backed against the corridor wall, and measures the
|
||||||
|
/// steady-state eye wander + ViewerCellId stability over 20k frames.
|
||||||
|
/// Diagnostic (reporting) first; the equilibrium fix turns the wander/flap
|
||||||
|
/// numbers into hard pins.
|
||||||
|
/// </summary>
|
||||||
|
public class Issue181WallPressEquilibriumTests
|
||||||
|
{
|
||||||
|
private const uint FacilityHubLandblock = 0x8A020000u;
|
||||||
|
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue181WallPressEquilibriumTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
// Mirrors AcDream.Core.Tests Conformance.ConformanceDats (not referencable
|
||||||
|
// from App.Tests): resolve the dat dir + load real EnvCells into the cache.
|
||||||
|
private static string? ResolveDatDir()
|
||||||
|
{
|
||||||
|
var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv;
|
||||||
|
var def = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||||
|
"Documents", "Asheron's Call");
|
||||||
|
return Directory.Exists(def) ? def : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (PhysicsEngine, PhysicsDataCache) BuildCorridorEngine(DatCollection dats)
|
||||||
|
{
|
||||||
|
var cache = new PhysicsDataCache();
|
||||||
|
var engine = new PhysicsEngine { DataCache = cache };
|
||||||
|
for (uint low = 0x0100u; low <= 0x01FFu; low++)
|
||||||
|
{
|
||||||
|
uint id = FacilityHubLandblock | low;
|
||||||
|
var datCell = dats.Get<DatEnvCell>(id);
|
||||||
|
if (datCell is null) continue;
|
||||||
|
var environment = dats.Get<DatEnvironment>(0x0D000000u | datCell.EnvironmentId);
|
||||||
|
if (environment is null) continue;
|
||||||
|
if (!environment.Cells.TryGetValue(datCell.CellStructure, out var cellStruct) || cellStruct is null)
|
||||||
|
continue;
|
||||||
|
var world = Matrix4x4.CreateFromQuaternion(datCell.Position.Orientation) *
|
||||||
|
Matrix4x4.CreateTranslation(datCell.Position.Origin);
|
||||||
|
cache.CacheCellStruct(id, datCell, cellStruct, world);
|
||||||
|
}
|
||||||
|
var heights = new byte[81];
|
||||||
|
var heightTable = new float[256];
|
||||||
|
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||||
|
engine.AddLandblock(FacilityHubLandblock, new TerrainSurface(heights, heightTable),
|
||||||
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return (engine, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability()
|
||||||
|
{
|
||||||
|
var datDir = ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var (engine, _) = BuildCorridorEngine(dats);
|
||||||
|
|
||||||
|
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||||
|
bool savedColl = CameraDiagnostics.CollideCamera;
|
||||||
|
float savedT = CameraDiagnostics.TranslationStiffness;
|
||||||
|
float savedR = CameraDiagnostics.RotationStiffness;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CameraDiagnostics.AlignToSlope = true;
|
||||||
|
CameraDiagnostics.CollideCamera = true;
|
||||||
|
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||||
|
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||||
|
|
||||||
|
// The live parked spot from the leak-fix log: player at the corridor
|
||||||
|
// spawn (cell 0x0142), backed near the +Y wall so the full boom is
|
||||||
|
// blocked (live [resolve]: hit=yes n=(0,-1,0) every frame).
|
||||||
|
var playerPos = new Vector3(50.331f, -39.357f, -5.90f);
|
||||||
|
// Live [resolve]: the sweep target headed (-2.13,+1.32,+0.75) from the
|
||||||
|
// pivot and hit the n=(0,-1,0) wall — so the player faces (+X,-Y)-ish
|
||||||
|
// and the boom presses -X+Y into that wall. yaw = atan2(-0.53, 0.85).
|
||||||
|
float yaw = -0.556f;
|
||||||
|
uint cellId = 0x8A020142u;
|
||||||
|
float dt = 1f / 1500f;
|
||||||
|
|
||||||
|
var cam = new RetailChaseCamera
|
||||||
|
{
|
||||||
|
CollisionProbe = new PhysicsCameraCollisionProbe(engine),
|
||||||
|
};
|
||||||
|
|
||||||
|
void Step() => cam.Update(
|
||||||
|
playerPosition: playerPos,
|
||||||
|
playerYaw: yaw,
|
||||||
|
playerVelocity: Vector3.Zero,
|
||||||
|
isOnGround: true,
|
||||||
|
contactPlaneNormal: Vector3.UnitZ,
|
||||||
|
dt: dt,
|
||||||
|
cellId: cellId,
|
||||||
|
selfEntityId: 0x5);
|
||||||
|
|
||||||
|
// Settle into the wall-press equilibrium.
|
||||||
|
for (int i = 0; i < 5000; i++) Step();
|
||||||
|
|
||||||
|
// Measure 20k steady-state frames.
|
||||||
|
var eyes = new List<Vector3>(20000);
|
||||||
|
var cells = new HashSet<uint>();
|
||||||
|
int cellTransitions = 0;
|
||||||
|
uint prevCell = cam.ViewerCellId;
|
||||||
|
Vector3 prevEye = cam.Position;
|
||||||
|
float maxStep = 0f; double sumStep = 0;
|
||||||
|
for (int i = 0; i < 20000; i++)
|
||||||
|
{
|
||||||
|
Step();
|
||||||
|
float d = Vector3.Distance(cam.Position, prevEye);
|
||||||
|
maxStep = MathF.Max(maxStep, d);
|
||||||
|
sumStep += d;
|
||||||
|
prevEye = cam.Position;
|
||||||
|
eyes.Add(cam.Position);
|
||||||
|
cells.Add(cam.ViewerCellId);
|
||||||
|
if (cam.ViewerCellId != prevCell) { cellTransitions++; prevCell = cam.ViewerCellId; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wander bounding box.
|
||||||
|
Vector3 mn = eyes[0], mx = eyes[0];
|
||||||
|
foreach (var e in eyes) { mn = Vector3.Min(mn, e); mx = Vector3.Max(mx, e); }
|
||||||
|
var span = mx - mn;
|
||||||
|
|
||||||
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
|
$"steady-state: avgStep={sumStep / 20000 * 1e6:F1}um maxStep={maxStep * 1e6:F1}um wanderBox=({span.X * 1000:F2},{span.Y * 1000:F2},{span.Z * 1000:F2})mm"));
|
||||||
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
|
$"viewer cells seen: {cells.Count} transitions={cellTransitions} eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6}) cell=0x{cam.ViewerCellId:X8}"));
|
||||||
|
|
||||||
|
// Orbit structure: 16 consecutive frames at 6dp, with the sweep's
|
||||||
|
// own [flap-sweep] lines captured for the same frames.
|
||||||
|
bool savedFlap = RenderingDiagnostics.ProbeFlapEnabled;
|
||||||
|
var savedOut = Console.Out;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
RenderingDiagnostics.ProbeFlapEnabled = true;
|
||||||
|
using var writer = new StringWriter();
|
||||||
|
Console.SetOut(writer);
|
||||||
|
for (int i = 0; i < 16; i++)
|
||||||
|
{
|
||||||
|
Step();
|
||||||
|
writer.WriteLine(FormattableString.Invariant(
|
||||||
|
$"orbit[{i:D2}] eye=({cam.Position.X:F6},{cam.Position.Y:F6},{cam.Position.Z:F6})"));
|
||||||
|
}
|
||||||
|
Console.SetOut(savedOut);
|
||||||
|
foreach (var line in writer.ToString().Split('\n'))
|
||||||
|
if (line.Length > 1) _out.WriteLine(line.TrimEnd());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Console.SetOut(savedOut);
|
||||||
|
RenderingDiagnostics.ProbeFlapEnabled = savedFlap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||||
|
CameraDiagnostics.CollideCamera = savedColl;
|
||||||
|
CameraDiagnostics.TranslationStiffness = savedT;
|
||||||
|
CameraDiagnostics.RotationStiffness = savedR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue