acdream/src/AcDream.Core/Physics/TerrainSurface.cs
Erik 833d167ebc fix(scenery): #49 9×9 loop, per-spawn building check, triangle slope
Three fixes to match retail CLandBlock::get_land_scenes (0x00530460):

1. Loop bound: iterate 9×9 vertices (side_vertex_count=9), not 8×8
   cells. Edge vertices (x=8 or y=8) produce valid spawns when the
   per-object displacement shifts the position back into [0, 192).
   Confirmed by named retail decomp do-while condition, WorldBuilder
   vertLength=9, ACViewer Terrain.Count=81, AC2D wTopo[9][9].

2. Building suppression: check at the DISPLACED position's cell
   (CSortCell::has_building per spawn), not at the loop vertex index.
   Matches WorldBuilder buildingsGrid[gx2, gy2] pattern.

3. Slope filter: replace finite-difference gradient approximation
   with triangle-aware normal sampling via new static method
   TerrainSurface.SampleNormalZFromHeightmap. Picks the correct
   triangle via IsSplitSWtoNE, matching retail find_terrain_poly →
   polygon->plane.N.z and WorldBuilder's GetNormal().

Tests: 5 new tests for SampleNormalZFromHeightmap (flat=1.0, sloped<1,
cross-validates with SampleSurface instance method) and DisplaceObject
edge-vertex validity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 21:15:11 +02:00

528 lines
22 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
namespace AcDream.Core.Physics;
public readonly record struct TerrainSurfacePolygon(
float Z,
Vector3 Normal,
Vector3[] Vertices);
/// <summary>
/// Outdoor terrain height resolver for a single landblock. Performs
/// per-triangle barycentric Z interpolation matching the visual terrain
/// mesh's triangle split direction (AC2D's FSplitNESW formula).
///
/// <para>
/// Each cell (24×24 units) is split into two triangles along either the
/// SW→NE or SE→NW diagonal. The split direction is determined by the
/// same formula the render mesh uses (0x0CCAC033 constants from AC2D),
/// so the Z this class produces matches the visual terrain surface exactly.
/// </para>
/// </summary>
public sealed class TerrainSurface
{
private const int HeightmapSide = 9;
private const float CellSize = 24f;
private const int CellsPerSide = 8; // 192 / 24
private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
private readonly byte[,] _cellWaterType; // per-CELL 0=NotWater, 1=Partially, 2=Entirely [cx, cy]
private readonly uint _landblockX;
private readonly uint _landblockY;
public TerrainSurface(byte[] heights, float[] heightTable,
uint landblockX = 0, uint landblockY = 0,
byte[]? terrainTypes = null)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
_landblockX = landblockX;
_landblockY = landblockY;
// Pre-resolve all 81 heights so SampleZ is a pure lookup + lerp.
_z = new float[HeightmapSide, HeightmapSide];
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
_z[x, y] = heightTable[heights[x * HeightmapSide + y]];
// Per-vertex water flag. TerrainType lives in bits 2-6 of each
// TerrainInfo byte; water is types 0x10-0x14 inclusive (per
// LandDefs.TerrainType enum: WaterRunning..WaterDeepSea).
// Retail's SurfChar[32] lookup marks those 5 indices as water.
// If terrainTypes is not provided (tests / legacy callers) we
// default to "no water anywhere" which preserves old behavior.
_cornerIsWater = new bool[HeightmapSide, HeightmapSide];
if (terrainTypes is not null && terrainTypes.Length >= 81)
{
for (int x = 0; x < HeightmapSide; x++)
for (int y = 0; y < HeightmapSide; y++)
{
int typeBits = (terrainTypes[x * HeightmapSide + y] >> 2) & 0x1F;
_cornerIsWater[x, y] = typeBits >= 0x10 && typeBits <= 0x14;
}
}
// Per-cell water classification (mirrors ACE
// LandblockStruct.CalcCellWater / CalcWater). A cell has 4
// vertex corners; count how many are water type.
_cellWaterType = new byte[CellsPerSide, CellsPerSide];
for (int cx = 0; cx < CellsPerSide; cx++)
for (int cy = 0; cy < CellsPerSide; cy++)
{
int waterCorners = 0;
if (_cornerIsWater[cx, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy ]) waterCorners++;
if (_cornerIsWater[cx + 1, cy + 1]) waterCorners++;
if (_cornerIsWater[cx, cy + 1]) waterCorners++;
_cellWaterType[cx, cy] = waterCorners switch
{
0 => 0, // NotWater
4 => 2, // EntirelyWater
_ => 1, // PartiallyWater
};
}
}
/// <summary>
/// Triangle-aware terrain Z at (localX, localY) in landblock-local
/// coordinates (0..192 range). Uses the decompiled retail client formula
/// (FUN_00532a50 / ACE LandblockStruct.ConstructPolygons) to pick one of
/// two diagonals, then does barycentric interpolation inside the chosen
/// triangle. Cross-verified against ACE's <c>LandCell.find_terrain_poly</c>
/// (plane-equation based), both produce identical Z for every (localX,localY).
///
/// <para>
/// Triangle layout matches ACE's ConstructPolygons (lines 221-244):
/// <b>SWtoNE</b> (bit31 set, <c>SWtoNEcut = true</c>): diagonal runs
/// <b>BL → TR</b> (line y = x). Triangles: {BL,BR,TR} below,
/// {BL,TR,TL} above. Dividing test: <c>tx &gt; ty</c>.
/// <b>SEtoNW</b> (bit31 clear, <c>SWtoNEcut = false</c>): diagonal runs
/// <b>BR → TL</b> (line x + y = 1). Triangles: {BL,BR,TL} below,
/// {BR,TR,TL} above. Dividing test: <c>tx + ty &lt;= 1</c>.
/// </para>
///
/// <para>
/// Diagnosed 2026-04-21: previous version had the two enum branches'
/// geometry inverted — when <c>splitSWtoNE</c> was <c>true</c> we
/// interpolated across the NW-SE diagonal (ACE's SEtoNW geometry) and
/// vice versa. Symptom: remote players drawn at server Z hovered up to
/// ~1m above or clipped into the rendered ground on sloped cells
/// because our surface Z came from the wrong triangle of the cell quad.
/// Flat cells masked the bug because all four corners shared one Z.
/// </para>
/// </summary>
public float SampleZ(float localX, float localY)
{
// Which cell?
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = (int)fx;
int cy = (int)fy;
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
// Fractional position within the cell [0, 1]
float tx = fx - cx;
float ty = fy - cy;
// Four corner heights (BL=SW, BR=SE, TR=NE, TL=NW)
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
// Split direction — same formula as TerrainBlending.CalculateSplitDirection
// and ACE's LandblockStruct.ConstructPolygons.
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
}
/// <summary>
/// Sample terrain Z directly from a landblock's raw heightmap. Same
/// algorithm as <see cref="SampleZ"/> (instance), but reads the four
/// corner heights through <c>heightTable[heights[x*9+y]]</c> on the fly
/// instead of from the pre-resolved instance cache. Use this when a
/// <see cref="TerrainSurface"/> hasn't been built yet for a landblock —
/// e.g. scenery hydration during streaming, before physics has registered
/// the landblock. Both paths produce the same Z, so scenery sits flush
/// with the visible terrain mesh and with the player physics path.
///
/// <para>
/// Issue #48 root cause: the previous bilinear fallback in
/// <c>GameWindow.SampleTerrainZ</c> had its two diagonal arms swapped
/// (used the SEtoNW triangle test for SWtoNE cells and vice versa),
/// so on sloped cells scenery sat at a different Z than the visible
/// terrain by up to ~1.5 m. Routing the fallback through this static
/// helper guarantees both samplers stay in lock-step.
/// </para>
/// </summary>
public static float SampleZFromHeightmap(
byte[] heights, float[] heightTable,
uint landblockX, uint landblockY,
float localX, float localY)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = (int)fx;
int cy = (int)fy;
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
// x-major heightmap indexing matches TerrainSurface's pre-resolution
// (heights[x * 9 + y]) and ACE LandblockStruct.
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
return InterpolateZInTriangle(hBL, hBR, hTR, hTL, tx, ty, splitSWtoNE);
}
/// <summary>
/// Sample the terrain triangle's surface-normal Z component at (localX, localY)
/// from a raw heightmap. Returns the upward component of the unit normal for
/// the specific triangle the point lies in — flat ground returns 1.0, steeper
/// slopes return smaller values. Used by <see cref="SceneryGenerator"/> for
/// the retail slope filter (<c>CLandCell::find_terrain_poly → polygon.plane.N.z</c>).
/// </summary>
public static float SampleNormalZFromHeightmap(
byte[] heights, float[] heightTable,
uint landblockX, uint landblockY,
float localX, float localY)
{
ArgumentNullException.ThrowIfNull(heights);
ArgumentNullException.ThrowIfNull(heightTable);
if (heights.Length < 81)
throw new ArgumentException("heights must have 81 entries", nameof(heights));
if (heightTable.Length < 256)
throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable));
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = (int)fx;
int cy = (int)fy;
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
float hBL = heightTable[heights[cx * HeightmapSide + cy ]];
float hBR = heightTable[heights[(cx+1) * HeightmapSide + cy ]];
float hTR = heightTable[heights[(cx+1) * HeightmapSide + (cy+1)]];
float hTL = heightTable[heights[cx * HeightmapSide + (cy+1)]];
bool splitSWtoNE = IsSplitSWtoNE(landblockX, (uint)cx, landblockY, (uint)cy);
float dzdx, dzdy;
if (splitSWtoNE)
{
if (tx > ty)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
}
else
{
if (tx + ty <= 1f)
{
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
else
{
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
}
return 1f / MathF.Sqrt(dzdx * dzdx + dzdy * dzdy + 1f);
}
/// <summary>
/// Pick the cell's triangle for the chosen diagonal and barycentric-
/// interpolate Z. Single source of truth shared by both
/// <see cref="SampleZ"/> (instance, pre-resolved cache) and
/// <see cref="SampleZFromHeightmap"/> (static, raw heightmap).
/// Triangle layout matches ACE <c>LandblockStruct.ConstructPolygons</c>:
/// SWtoNE cells split BL→TR (line y=x), SEtoNW cells split BR→TL
/// (line x+y=1).
/// </summary>
private static float InterpolateZInTriangle(
float hBL, float hBR, float hTR, float hTL,
float tx, float ty, bool splitSWtoNE)
{
if (splitSWtoNE)
{
// Diagonal BL(0,0) → TR(1,1) — line y = x.
// Triangles: {BL,BR,TR} below (tx > ty), {BL,TR,TL} above.
if (tx > ty)
return hBL + (hBR - hBL) * tx + (hTR - hBR) * ty; // BL+BR+TR
return hBL + (hTR - hTL) * tx + (hTL - hBL) * ty; // BL+TR+TL
}
else
{
// Diagonal BR(1,0) → TL(0,1) — line x + y = 1.
// Triangles: {BL,BR,TL} below (tx+ty <= 1), {BR,TR,TL} above.
if (tx + ty <= 1f)
return hBL + (hBR - hBL) * tx + (hTL - hBL) * ty; // BL+BR+TL
return hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty); // BR+TR+TL
}
}
/// <summary>
/// Sample both the terrain Z and the triangle-plane surface normal at
/// (localX, localY). The normal is derived from the gradient of the same
/// triangle <see cref="SampleZ"/> interpolates across, so returned
/// <c>(Z, Normal)</c> is exactly the sloped plane the physics should
/// contact against.
///
/// <para>
/// This matters for <see cref="Transition"/>: when <c>AdjustOffset</c>
/// projects the per-step movement offset onto the contact plane, a flat
/// plane (<c>Normal = (0,0,1)</c>) cannot impart any Z component to a
/// horizontal velocity — the character walks off a slope and the
/// per-frame step-down budget (~4 cm) can't catch up with the slope
/// descent rate, so the sphere floats above the terrain. A SLOPED plane
/// gives AdjustOffset the info it needs to produce slope-aligned motion
/// with the correct Z component baked in.
/// </para>
///
/// <para>
/// Retail does this via <c>LandCell.find_terrain_poly → walkable.Plane</c>
/// (ACE <c>Landblock.cs:125-137</c>). We derive the equivalent plane
/// analytically from the chosen triangle's three corner heights.
/// </para>
/// </summary>
public (float Z, System.Numerics.Vector3 Normal) SampleSurface(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = (int)fx;
int cy = (int)fy;
cx = Math.Clamp(cx, 0, CellsPerSide - 1);
cy = Math.Clamp(cy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
// The SampleZ formula for each triangle is linear in (tx, ty):
// Z = a + b * tx + c * ty
// so dZ/dLocalX = b / CellSize, dZ/dLocalY = c / CellSize.
// Surface normal = normalize((-dZ/dX, -dZ/dY, 1)) — a well-known
// identity for a height-field plane.
float z, dzdx, dzdy;
if (splitSWtoNE)
{
// Diagonal BL(0,0) → TR(1,1). Triangles: {BL,BR,TR} / {BL,TR,TL}.
if (tx > ty)
{
// {BL,BR,TR}: Z = hBL + (hBR-hBL)·tx + (hTR-hBR)·ty
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
else
{
// {BL,TR,TL}: Z = hBL + (hTR-hTL)·tx + (hTL-hBL)·ty
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
}
else
{
// Diagonal BR(1,0) → TL(0,1). Triangles: {BL,BR,TL} / {BR,TR,TL}.
if (tx + ty <= 1f)
{
// {BL,BR,TL}: Z = hBL + (hBR-hBL)·tx + (hTL-hBL)·ty
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
dzdx = (hBR - hBL) / CellSize;
dzdy = (hTL - hBL) / CellSize;
}
else
{
// {BR,TR,TL}: Z = hTR + (hTL-hTR)(1-tx) + (hBR-hTR)(1-ty)
// Equivalent linear form: Z = [hBR+hTL-hTR] + (hTR-hTL)·tx + (hTR-hBR)·ty
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
dzdx = (hTR - hTL) / CellSize;
dzdy = (hTR - hBR) / CellSize;
}
}
var normal = System.Numerics.Vector3.Normalize(
new System.Numerics.Vector3(-dzdx, -dzdy, 1f));
return (z, normal);
}
/// <summary>
/// Sample the terrain triangle at (localX, localY), including the three
/// local-space vertices that bound the sampled point. Edge-slide needs
/// these vertices so the retail crossed-edge test can identify which edge
/// the sphere left when a step-down probe fails.
/// </summary>
public TerrainSurfacePolygon SampleSurfacePolygon(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
float tx = fx - cx;
float ty = fy - cy;
float hBL = _z[cx, cy ];
float hBR = _z[cx + 1, cy ];
float hTR = _z[cx + 1, cy + 1];
float hTL = _z[cx, cy + 1];
bool splitSWtoNE = IsSplitSWtoNE(_landblockX, (uint)cx, _landblockY, (uint)cy);
Vector3 bl = new(cx * CellSize, cy * CellSize, hBL);
Vector3 br = new((cx + 1) * CellSize, cy * CellSize, hBR);
Vector3 tr = new((cx + 1) * CellSize, (cy + 1) * CellSize, hTR);
Vector3 tl = new(cx * CellSize, (cy + 1) * CellSize, hTL);
float z;
Vector3[] vertices;
if (splitSWtoNE)
{
if (tx > ty)
{
z = hBL + (hBR - hBL) * tx + (hTR - hBR) * ty;
vertices = new[] { bl, br, tr };
}
else
{
z = hBL + (hTR - hTL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, tr, tl };
}
}
else
{
if (tx + ty <= 1f)
{
z = hBL + (hBR - hBL) * tx + (hTL - hBL) * ty;
vertices = new[] { bl, br, tl };
}
else
{
z = hTR + (hTL - hTR) * (1f - tx) + (hBR - hTR) * (1f - ty);
vertices = new[] { br, tr, tl };
}
}
var normal = Vector3.Normalize(
Vector3.Cross(vertices[1] - vertices[0], vertices[2] - vertices[0]));
if (normal.Z < 0f)
normal = -normal;
return new TerrainSurfacePolygon(z, normal, vertices);
}
/// <summary>
/// Retail per-point water depth in meters — the amount the character's
/// feet are allowed to sink below the contact plane before the
/// push-up fires. <see cref="ValidateWalkable"/> adds this to the
/// signed-distance check so water cells visually submerge the
/// character while dry terrain keeps feet exactly on the plane.
///
/// <para>
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
/// 0.1 fallback for "dry corner of a partially-water cell" is
/// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on
/// plane" contact-touch check (dist > EPSILON, so SetContactPlane
/// doesn't fire, ValidateTransition clears OnWalkable, gravity
/// applies, character floats/falls each frame). The visible effect
/// in retail is subtle (~10 cm natural sink-in) so dropping it to 0
/// is indistinguishable.
/// </para>
/// <list type="bullet">
/// <item>NotWater cell → <c>0.0f</c></item>
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</item>
/// <item>PartiallyWater cell, nearest-corner water type → <c>0.45f</c></item>
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
/// </list>
/// </summary>
public float SampleWaterDepth(float localX, float localY)
{
float fx = Math.Clamp(localX / CellSize, 0f, CellsPerSide - 0.001f);
float fy = Math.Clamp(localY / CellSize, 0f, CellsPerSide - 0.001f);
int cx = Math.Clamp((int)fx, 0, CellsPerSide - 1);
int cy = Math.Clamp((int)fy, 0, CellsPerSide - 1);
byte waterType = _cellWaterType[cx, cy];
if (waterType == 0) return 0f; // NotWater
if (waterType == 2) return 0.9f; // EntirelyWater
// PartiallyWater — resolve to nearest corner (retail picks the
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
// a 24m cell rounds up). Return 0.45 for water corner, 0 for dry
// (retail uses 0.1 for dry corners; see note above).
float tx = fx - cx;
float ty = fy - cy;
int vx = cx + (tx >= 0.5f ? 1 : 0);
int vy = cy + (ty >= 0.5f ? 1 : 0);
return _cornerIsWater[vx, vy] ? 0.45f : 0f;
}
/// <summary>
/// Compute the outdoor cell ID for the given landblock-local position.
/// </summary>
public uint ComputeOutdoorCellId(float localX, float localY)
{
int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
return (uint)(1 + cx * CellsPerSide + cy);
}
/// <summary>
/// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal.
/// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY).
/// </summary>
private static bool IsSplitSWtoNE(uint landblockX, uint cellX, uint landblockY, uint cellY)
{
uint x = landblockX * 8 + cellX;
uint y = landblockY * 8 + cellY;
uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
return (dw & 0x80000000u) != 0;
}
}