Rework SWE boundary control to ghost inflows and boundary profiles

This commit is contained in:
2026-02-10 22:02:20 +01:00
parent 662278858b
commit 9aa9daee79
7 changed files with 1434 additions and 96 deletions

View File

@@ -10,6 +10,9 @@ Texture2D<float> _WaterEast;
Texture2D<float2> _VelEast;
Texture2D<float> _WaterWest;
Texture2D<float2> _VelWest;
Texture2D<float> _GhostOverrideMask;
Texture2D<float> _GhostOverrideWater;
Texture2D<float2> _GhostOverrideVel;
RWTexture2D<float> _WaterOut;
RWTexture2D<float2> _VelOut;
@@ -19,6 +22,7 @@ int _HasNorth;
int _HasSouth;
int _HasEast;
int _HasWest;
int _UseGhostOverride;
[numthreads(8, 8, 1)]
void GhostExchange(uint3 id : SV_DispatchThreadID)
@@ -73,6 +77,16 @@ void GhostExchange(uint3 id : SV_DispatchThreadID)
}
}
if (_UseGhostOverride == 1 && (g.x == 0 || g.x == size - 1 || g.y == 0 || g.y == size - 1))
{
float mask = _GhostOverrideMask[g];
if (mask > 0.5)
{
h = _GhostOverrideWater[g];
v = _GhostOverrideVel[g];
}
}
if (h != h || h < 0.0)
{
h = 0.0;

View File

@@ -127,14 +127,20 @@ namespace FloodSWE.IO
{
simulator.porosity = data.Porosity;
}
simulator.sourceIdMask = data.SourceIds;
simulator.sinkIdMask = data.SinkIds;
simulator.ApplyTileStaticData(
data.Height,
data.Porosity,
data.SourceIds,
data.SinkIds,
data.Resolution,
data.TileSizeM);
if (verboseDiagnostics)
{
Debug.Log(
$"SweTileLoader: applied tile {MakeTileKeyLabel(data.Lod, data.TileX, data.TileY)} " +
$"to simulator (height={DescribeTexture(data.Height)}, porosity={DescribeTexture(data.Porosity)}, " +
$"to simulator (res={data.Resolution}, tileSize={data.TileSizeM:0.###}m, " +
$"height={DescribeTexture(data.Height)}, porosity={DescribeTexture(data.Porosity)}, " +
$"sourceMask={DescribeTexture(data.SourceIds)}, sinkMask={DescribeTexture(data.SinkIds)}).");
}
@@ -223,6 +229,8 @@ namespace FloodSWE.IO
lod,
tileX,
tileY,
ParseFloat(parts[7], 1000.0f),
ParseInt(parts[8], 256),
NormalizePath(parts[9]),
NormalizePath(parts[10]),
NormalizePath(parts[11])
@@ -405,6 +413,8 @@ namespace FloodSWE.IO
var sb = new StringBuilder(256);
sb.Append("tile=").Append(requestedTile)
.Append("; index=").Append(indexRecordCount)
.Append("; res=").Append(record.Resolution)
.Append("; tileSize=").Append(record.TileSizeM.ToString("0.###", CultureInfo.InvariantCulture))
.Append("; height=").Append(DescribeTexture(height))
.Append("; porosity=").Append(DescribeTexture(porosity))
.Append("; buildings=").Append(DescribeTexture(buildings))
@@ -468,6 +478,24 @@ namespace FloodSWE.IO
return $"{texture.width}x{texture.height},readable={texture.isReadable}";
}
private static float ParseFloat(string raw, float fallback)
{
if (float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed))
{
return parsed;
}
return fallback;
}
private static int ParseInt(string raw, int fallback)
{
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed))
{
return parsed;
}
return fallback;
}
private readonly struct TileKey : IEquatable<TileKey>
{
public readonly string Lod;
@@ -509,15 +537,27 @@ namespace FloodSWE.IO
public readonly string Lod;
public readonly int X;
public readonly int Y;
public readonly float TileSizeM;
public readonly int Resolution;
public readonly string HeightPath;
public readonly string PorosityPath;
public readonly string BuildingPath;
public TileRecord(string lod, int x, int y, string heightPath, string porosityPath, string buildingPath)
public TileRecord(
string lod,
int x,
int y,
float tileSizeM,
int resolution,
string heightPath,
string porosityPath,
string buildingPath)
{
Lod = lod;
X = x;
Y = y;
TileSizeM = tileSizeM;
Resolution = resolution;
HeightPath = heightPath;
PorosityPath = porosityPath;
BuildingPath = buildingPath;
@@ -530,6 +570,8 @@ namespace FloodSWE.IO
public readonly string Lod;
public readonly int TileX;
public readonly int TileY;
public readonly float TileSizeM;
public readonly int Resolution;
public readonly Texture2D Height;
public readonly Texture2D Porosity;
public readonly Texture2D Buildings;
@@ -547,6 +589,8 @@ namespace FloodSWE.IO
Lod = record.Lod;
TileX = record.X;
TileY = record.Y;
TileSizeM = record.TileSizeM;
Resolution = record.Resolution;
Height = height;
Porosity = porosity;
Buildings = buildings;

View File

@@ -79,6 +79,13 @@ public sealed class SweTileSimulator : MonoBehaviour
private Texture2D externalDepthRateMap;
private float[] externalDepthRateMapData;
private bool useExternalDepthRateMap;
private Texture2D ghostOverrideMask;
private Texture2D ghostOverrideWater;
private Texture2D ghostOverrideVelocity;
private float[] ghostOverrideMaskData;
private float[] ghostOverrideWaterData;
private Vector2[] ghostOverrideVelocityData;
private bool useGhostBoundaryOverrides;
private RenderTexture clampMask;
private RenderTexture dummyRw;
@@ -163,6 +170,47 @@ public sealed class SweTileSimulator : MonoBehaviour
externalDepthRateMps = metersPerSecond;
}
public void ApplyTileStaticData(
Texture2D terrain,
Texture2D porosityTexture,
Texture2D sourceMask,
Texture2D sinkMaskTexture,
int resolution,
float tileSizeM)
{
if (terrain != null)
{
terrainHeight = terrain;
}
if (porosityTexture != null)
{
porosity = porosityTexture;
}
sourceIdMask = sourceMask;
sinkIdMask = sinkMaskTexture;
int targetResolution = Mathf.Max(4, resolution);
float targetTileSize = Mathf.Max(1.0f, tileSizeM);
bool requiresReinit = targetResolution != gridRes || Mathf.Abs(targetTileSize - tileSizeMeters) > 0.001f;
gridRes = targetResolution;
tileSizeMeters = targetTileSize;
if (!isInitialized)
{
return;
}
if (requiresReinit)
{
Release();
Initialize();
return;
}
ResolveStaticTextures();
ResetSimulationState();
}
public bool SetExternalDepthRateMap(float[] metersPerSecondPerCell)
{
if (!isInitialized || metersPerSecondPerCell == null || metersPerSecondPerCell.Length != gridRes * gridRes)
@@ -192,6 +240,72 @@ public sealed class SweTileSimulator : MonoBehaviour
useExternalDepthRateMap = false;
}
public bool SetGhostBoundaryOverrides(int[] ghostIndices, float[] waterLevels, Vector2[] velocities)
{
if (!isInitialized)
{
return false;
}
if (ghostIndices == null || waterLevels == null || velocities == null)
{
return false;
}
if (ghostIndices.Length == 0 || ghostIndices.Length != waterLevels.Length || ghostIndices.Length != velocities.Length)
{
return false;
}
EnsureGhostOverrideTextures();
Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length);
Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length);
Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length);
bool any = false;
int maxIndex = ghostOverrideMaskData.Length - 1;
for (int i = 0; i < ghostIndices.Length; i++)
{
int idx = ghostIndices[i];
if (idx < 0 || idx > maxIndex)
{
continue;
}
any = true;
ghostOverrideMaskData[idx] = 1.0f;
ghostOverrideWaterData[idx] = Mathf.Max(0.0f, waterLevels[i]);
ghostOverrideVelocityData[idx] = velocities[i];
}
ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0);
ghostOverrideMask.Apply(false, false);
ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0);
ghostOverrideWater.Apply(false, false);
ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0);
ghostOverrideVelocity.Apply(false, false);
useGhostBoundaryOverrides = any;
return any;
}
public void ClearGhostBoundaryOverrides()
{
if (!isInitialized)
{
return;
}
EnsureGhostOverrideTextures();
Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length);
Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length);
Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length);
ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0);
ghostOverrideMask.Apply(false, false);
ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0);
ghostOverrideWater.Apply(false, false);
ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0);
ghostOverrideVelocity.Apply(false, false);
useGhostBoundaryOverrides = false;
}
public bool TryGetBoundaryIdMasks(out int[] sourceIds, out int[] sinkIds)
{
sourceIds = null;
@@ -446,28 +560,8 @@ public sealed class SweTileSimulator : MonoBehaviour
}
ResolveStaticTextures();
ClearRenderTexture(waterB);
ClearRenderTexture(velB);
ClearRenderTexture(waterGhost);
ClearRenderTexture(velGhost);
fluxShader.SetInt("_GridRes", gridRes);
fluxShader.SetFloat("_InitDepthLeft", initialDepthLeft);
fluxShader.SetFloat("_InitDepthRight", initialDepthRight);
fluxShader.SetVector("_InitVelocity", initialVelocity);
fluxShader.SetInt("_InitDamBreak", damBreak ? 1 : 0);
fluxShader.SetTexture(initKernel, "_WaterOut", waterA);
fluxShader.SetTexture(initKernel, "_VelOut", velA);
DispatchKernel(fluxShader, initKernel, gridRes, gridRes);
useA = true;
debugWater = CurrentWater;
debugVelocity = CurrentVelocity;
lastMaxDepth = Mathf.Max(initialDepthLeft, initialDepthRight);
lastMaxSpeed = Mathf.Sqrt(Gravity * Mathf.Max(lastMaxDepth, 0.0f));
isInitialized = true;
ResetSimulationState();
}
private void Release()
@@ -490,6 +584,68 @@ public sealed class SweTileSimulator : MonoBehaviour
clampMask = null;
dummyRw = null;
ReleaseResolvedStaticTextures();
if (externalDepthRateMap != null)
{
Destroy(externalDepthRateMap);
}
if (ghostOverrideMask != null)
{
Destroy(ghostOverrideMask);
}
if (ghostOverrideWater != null)
{
Destroy(ghostOverrideWater);
}
if (ghostOverrideVelocity != null)
{
Destroy(ghostOverrideVelocity);
}
resolvedTerrain = null;
resolvedPorosity = null;
resolvedSourceIdMask = null;
resolvedSinkIdMask = null;
externalDepthRateMap = null;
externalDepthRateMapData = null;
useExternalDepthRateMap = false;
ghostOverrideMask = null;
ghostOverrideWater = null;
ghostOverrideVelocity = null;
ghostOverrideMaskData = null;
ghostOverrideWaterData = null;
ghostOverrideVelocityData = null;
useGhostBoundaryOverrides = false;
}
private void ResolveStaticTextures()
{
ReleaseResolvedStaticTextures();
resolvedTerrain = ResolveTerrainTexture();
resolvedPorosity = ResolvePorosityTexture();
resolvedSourceIdMask = ResolveBoundaryMaskTexture(sourceIdMask, "SWE_SourceIds");
resolvedSinkIdMask = ResolveBoundaryMaskTexture(sinkIdMask, "SWE_SinkIds");
useExternalDepthRateMap = false;
EnsureExternalDepthRateMap();
Array.Clear(externalDepthRateMapData, 0, externalDepthRateMapData.Length);
externalDepthRateMap.SetPixelData(externalDepthRateMapData, 0);
externalDepthRateMap.Apply(false, false);
EnsureGhostOverrideTextures();
Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length);
Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length);
Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length);
ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0);
ghostOverrideMask.Apply(false, false);
ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0);
ghostOverrideWater.Apply(false, false);
ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0);
ghostOverrideVelocity.Apply(false, false);
useGhostBoundaryOverrides = false;
}
private void ReleaseResolvedStaticTextures()
{
if (resolvedTerrain != null && resolvedTerrain != terrainHeight)
{
Destroy(resolvedTerrain);
@@ -509,29 +665,38 @@ public sealed class SweTileSimulator : MonoBehaviour
{
Destroy(resolvedSinkIdMask);
}
if (externalDepthRateMap != null)
{
Destroy(externalDepthRateMap);
}
resolvedTerrain = null;
resolvedPorosity = null;
resolvedSourceIdMask = null;
resolvedSinkIdMask = null;
externalDepthRateMap = null;
externalDepthRateMapData = null;
useExternalDepthRateMap = false;
}
private void ResolveStaticTextures()
private void ResetSimulationState()
{
resolvedTerrain = ResolveTerrainTexture();
resolvedPorosity = ResolvePorosityTexture();
resolvedSourceIdMask = ResolveBoundaryMaskTexture(sourceIdMask, "SWE_SourceIds");
resolvedSinkIdMask = ResolveBoundaryMaskTexture(sinkIdMask, "SWE_SinkIds");
useExternalDepthRateMap = false;
EnsureExternalDepthRateMap();
if (waterA == null || velA == null || waterB == null || velB == null)
{
return;
}
ClearRenderTexture(waterA);
ClearRenderTexture(velA);
ClearRenderTexture(waterB);
ClearRenderTexture(velB);
ClearRenderTexture(waterGhost);
ClearRenderTexture(velGhost);
fluxShader.SetInt("_GridRes", gridRes);
fluxShader.SetFloat("_InitDepthLeft", initialDepthLeft);
fluxShader.SetFloat("_InitDepthRight", initialDepthRight);
fluxShader.SetVector("_InitVelocity", initialVelocity);
fluxShader.SetInt("_InitDamBreak", damBreak ? 1 : 0);
fluxShader.SetTexture(initKernel, "_WaterOut", waterA);
fluxShader.SetTexture(initKernel, "_VelOut", velA);
DispatchKernel(fluxShader, initKernel, gridRes, gridRes);
useA = true;
tickIndex = 0;
pendingMassScale = 1.0f;
lastMaxDepth = Mathf.Max(initialDepthLeft, initialDepthRight);
lastMaxSpeed = Mathf.Sqrt(Gravity * Mathf.Max(lastMaxDepth, 0.0f));
debugWater = CurrentWater;
debugVelocity = CurrentVelocity;
}
private Texture2D ResolveTerrainTexture()
@@ -698,11 +863,17 @@ public sealed class SweTileSimulator : MonoBehaviour
private void DispatchGhost()
{
if (ghostOverrideMask == null || ghostOverrideWater == null || ghostOverrideVelocity == null)
{
EnsureGhostOverrideTextures();
}
ghostExchangeShader.SetInt("_GridRes", gridRes);
ghostExchangeShader.SetInt("_HasNorth", 0);
ghostExchangeShader.SetInt("_HasSouth", 0);
ghostExchangeShader.SetInt("_HasEast", 0);
ghostExchangeShader.SetInt("_HasWest", 0);
ghostExchangeShader.SetInt("_UseGhostOverride", useGhostBoundaryOverrides ? 1 : 0);
ghostExchangeShader.SetTexture(ghostKernel, "_WaterIn", CurrentWater);
ghostExchangeShader.SetTexture(ghostKernel, "_VelIn", CurrentVelocity);
@@ -716,6 +887,9 @@ public sealed class SweTileSimulator : MonoBehaviour
ghostExchangeShader.SetTexture(ghostKernel, "_VelWest", CurrentVelocity);
ghostExchangeShader.SetTexture(ghostKernel, "_WaterOut", waterGhost);
ghostExchangeShader.SetTexture(ghostKernel, "_VelOut", velGhost);
ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideMask", ghostOverrideMask);
ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideWater", ghostOverrideWater);
ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideVel", ghostOverrideVelocity);
DispatchKernel(ghostExchangeShader, ghostKernel, gridRes + 2, gridRes + 2);
}
@@ -1112,6 +1286,64 @@ public sealed class SweTileSimulator : MonoBehaviour
}
}
private void EnsureGhostOverrideTextures()
{
int ghostRes = gridRes + 2;
int length = ghostRes * ghostRes;
if (ghostOverrideMask == null || ghostOverrideMask.width != ghostRes || ghostOverrideMask.height != ghostRes)
{
if (ghostOverrideMask != null)
{
Destroy(ghostOverrideMask);
}
ghostOverrideMask = CreateTextureFromFloats(new float[length], ghostRes, ghostRes, "SWE_GhostOverrideMask");
ghostOverrideMaskData = new float[length];
}
else if (ghostOverrideMaskData == null || ghostOverrideMaskData.Length != length)
{
ghostOverrideMaskData = new float[length];
}
if (ghostOverrideWater == null || ghostOverrideWater.width != ghostRes || ghostOverrideWater.height != ghostRes)
{
if (ghostOverrideWater != null)
{
Destroy(ghostOverrideWater);
}
ghostOverrideWater = CreateTextureFromFloats(new float[length], ghostRes, ghostRes, "SWE_GhostOverrideWater");
ghostOverrideWaterData = new float[length];
}
else if (ghostOverrideWaterData == null || ghostOverrideWaterData.Length != length)
{
ghostOverrideWaterData = new float[length];
}
if (ghostOverrideVelocity == null || ghostOverrideVelocity.width != ghostRes || ghostOverrideVelocity.height != ghostRes)
{
if (ghostOverrideVelocity != null)
{
Destroy(ghostOverrideVelocity);
}
ghostOverrideVelocity = new Texture2D(ghostRes, ghostRes, TextureFormat.RGFloat, false, true)
{
name = "SWE_GhostOverrideVelocity",
wrapMode = TextureWrapMode.Clamp,
filterMode = FilterMode.Point
};
ghostOverrideVelocityData = new Vector2[length];
ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0);
ghostOverrideVelocity.Apply(false, false);
}
else if (ghostOverrideVelocityData == null || ghostOverrideVelocityData.Length != length)
{
ghostOverrideVelocityData = new Vector2[length];
}
}
private static int[] ReadIdTexture(Texture2D texture)
{
if (texture == null || !texture.isReadable)

View File

@@ -22,12 +22,20 @@ namespace FloodSWE.Networking
private IPEndPoint serverEndpoint;
private float lastAckTimeUnscaled = -1.0f;
private float lastHelloSentTimeUnscaled = -1.0f;
private float lastStateTimeUnscaled = -1.0f;
private string lastAckMessage = "";
private SweBoundaryStateMessage lastBoundaryState;
private bool connectRequested;
public event Action<SweBoundaryStateMessage> BoundaryStateReceived;
public bool HasReceivedAck => lastAckTimeUnscaled >= 0.0f;
public float LastAckAgeSeconds => HasReceivedAck ? Time.unscaledTime - lastAckTimeUnscaled : float.PositiveInfinity;
public string LastAckMessage => lastAckMessage;
public bool HasBoundaryState => lastStateTimeUnscaled >= 0.0f;
public float LastBoundaryStateAgeSeconds =>
HasBoundaryState ? Time.unscaledTime - lastStateTimeUnscaled : float.PositiveInfinity;
public SweBoundaryStateMessage LastBoundaryState => lastBoundaryState;
public bool IsConnectionAlive => HasReceivedAck && LastAckAgeSeconds <= Mathf.Max(0.1f, ackTimeoutSeconds);
public bool IsWaitingForAck => connectRequested && !IsConnectionAlive && !HasReceivedAck;
@@ -46,7 +54,7 @@ namespace FloodSWE.Networking
private void Update()
{
PollAcks();
PollMessages();
TickKeepAlive();
}
@@ -89,7 +97,9 @@ namespace FloodSWE.Networking
serverEndpoint = null;
lastAckTimeUnscaled = -1.0f;
lastStateTimeUnscaled = -1.0f;
lastAckMessage = "";
lastBoundaryState = null;
connectRequested = false;
}
@@ -104,21 +114,79 @@ namespace FloodSWE.Networking
public void SetSourceLevel(int sourceId, float level)
{
Send(new SweControlCommand
{
command = "set_source_level",
sourceId = sourceId,
sourceLevel = level,
});
SetBoundaryProfile(
"source_area",
sourceId,
true,
0.0f,
0.0f,
0.0f,
Mathf.Max(0.0f, level));
}
public void SetSinkLevel(int sinkId, float level)
{
SetBoundaryProfile(
"sink",
sinkId,
true,
0.0f,
0.0f,
0.0f,
-Mathf.Max(0.0f, level));
}
public void SetBoundaryProfile(
string boundaryKind,
int boundaryId,
bool enabled,
float waterLevelM,
float velocityUMps,
float velocityVMps,
float depthRateMps)
{
Send(new SweControlCommand
{
command = "set_sink_level",
sinkId = sinkId,
sinkLevel = level,
command = "set_boundary_profile",
boundaryKind = boundaryKind,
boundaryId = boundaryId,
enabled = enabled ? 1 : 0,
waterLevelM = waterLevelM,
velocityUMps = velocityUMps,
velocityVMps = velocityVMps,
depthRateMps = depthRateMps,
});
}
public void SetBoundariesBulk(SweBoundaryProfile[] profiles, bool replaceAll = false)
{
if (profiles == null)
{
return;
}
Send(new SweControlCommand
{
command = "set_boundaries_bulk",
replaceAll = replaceAll,
boundaries = profiles,
});
}
public void RequestBoundaryConfig()
{
Send(new SweControlCommand
{
command = "get_boundary_config",
});
}
public void SubscribeBoundaryUpdates(bool subscribe = true)
{
Send(new SweControlCommand
{
command = "subscribe_boundary_updates",
subscribe = subscribe,
});
}
@@ -214,7 +282,7 @@ namespace FloodSWE.Networking
socket.Send(payload, payload.Length, serverEndpoint);
}
private void PollAcks()
private void PollMessages()
{
if (socket == null)
{
@@ -234,17 +302,33 @@ namespace FloodSWE.Networking
break;
}
if (!SweUdpProtocol.TryDecodeAck(payload, out string message))
if (SweUdpProtocol.TryDecodeAck(payload, out string message))
{
lastAckTimeUnscaled = Time.unscaledTime;
lastAckMessage = message ?? "";
if (verboseLogging)
{
Debug.Log($"SweQuestControlClient: ack from {sender}: {lastAckMessage}");
}
continue;
}
lastAckTimeUnscaled = Time.unscaledTime;
lastAckMessage = message ?? "";
if (verboseLogging)
if (SweUdpProtocol.TryDecodeState(payload, out SweBoundaryStateMessage state))
{
Debug.Log($"SweQuestControlClient: ack from {sender}: {lastAckMessage}");
lastStateTimeUnscaled = Time.unscaledTime;
lastBoundaryState = state;
BoundaryStateReceived?.Invoke(state);
if (verboseLogging)
{
int count = state != null && state.boundaries != null ? state.boundaries.Length : 0;
Debug.Log(
$"SweQuestControlClient: boundary state from {sender}: type={state?.messageType} entries={count}");
}
continue;
}
}
}

View File

@@ -57,16 +57,23 @@ namespace FloodSWE.Networking
private readonly Dictionary<int, float> sourceLevels = new Dictionary<int, float>();
private readonly Dictionary<int, float> sinkLevels = new Dictionary<int, float>();
private readonly Dictionary<string, SweBoundaryProfile> boundaryProfiles =
new Dictionary<string, SweBoundaryProfile>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, SweCheckpointState> checkpoints = new Dictionary<string, SweCheckpointState>(StringComparer.OrdinalIgnoreCase);
private SweBoundaryManifest boundaryManifest;
private HashSet<int> activeSourceIds = new HashSet<int>();
private HashSet<int> activeSinkIds = new HashSet<int>();
private readonly Dictionary<int, int[]> activeBoundaryInflowGhostCells = new Dictionary<int, int[]>();
private readonly Dictionary<int, int[]> activeSourceAreaCells = new Dictionary<int, int[]>();
private readonly Dictionary<int, int[]> activeSinkCells = new Dictionary<int, int[]>();
private bool activeTileHasBoundaryCellGroups;
private UdpClient commandSocket;
private IPEndPoint commandEndpoint;
private IPEndPoint questEndpoint;
private readonly Dictionary<string, IPEndPoint> frameSubscribers = new Dictionary<string, IPEndPoint>(StringComparer.Ordinal);
private readonly Dictionary<string, IPEndPoint> stateSubscribers = new Dictionary<string, IPEndPoint>(StringComparer.Ordinal);
private int lastOversizeWarningFrameId = -1;
private string lastMissingMaskWarningTileKey = "";
private int receivedControlPackets;
@@ -335,6 +342,7 @@ namespace FloodSWE.Networking
lastClientChangeTimeUnscaled = Time.unscaledTime;
}
frameSubscribers.Clear();
stateSubscribers.Clear();
}
private void PollCommands()
@@ -390,15 +398,56 @@ namespace FloodSWE.Networking
switch (cmd)
{
case "set_source_level":
sourceLevels[command.sourceId] = command.sourceLevel;
RecomputeExternalDepthRate();
SendAck(sender, $"source {command.sourceId} level={command.sourceLevel:F3}");
ApplyLegacySourceLevel(command.sourceId, command.sourceLevel);
SendAck(sender, $"legacy source {command.sourceId} level={command.sourceLevel:F3}");
break;
case "set_sink_level":
sinkLevels[command.sinkId] = command.sinkLevel;
RecomputeExternalDepthRate();
SendAck(sender, $"sink {command.sinkId} level={command.sinkLevel:F3}");
ApplyLegacySinkLevel(command.sinkId, command.sinkLevel);
SendAck(sender, $"legacy sink {command.sinkId} level={command.sinkLevel:F3}");
break;
case "set_boundary_profile":
if (TryApplyBoundaryProfileCommand(command, out string profileResult))
{
SendAck(sender, profileResult);
BroadcastBoundaryState("update");
}
else
{
SendAck(sender, "set_boundary_profile rejected");
}
break;
case "set_boundaries_bulk":
if (TryApplyBoundaryBulkCommand(command, out string bulkResult))
{
SendAck(sender, bulkResult);
BroadcastBoundaryState("update");
}
else
{
SendAck(sender, "set_boundaries_bulk rejected");
}
break;
case "get_boundary_config":
SendBoundaryState(sender, "config");
SendAck(sender, "boundary_config_sent");
break;
case "subscribe_boundary_updates":
if (command.subscribe)
{
AddStateSubscriber(sender);
SendBoundaryState(sender, "config");
SendAck(sender, "boundary_updates_subscribed");
}
else
{
RemoveStateSubscriber(sender);
SendAck(sender, "boundary_updates_unsubscribed");
}
break;
case "set_active_tile":
@@ -444,6 +493,7 @@ namespace FloodSWE.Networking
case "disconnect":
RemoveFrameSubscriber(sender.Address, questFramePort);
RemoveStateSubscriber(sender);
SendAck(sender, "disconnected");
break;
@@ -467,6 +517,7 @@ namespace FloodSWE.Networking
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
RefreshActiveTileBoundaryIds();
RecomputeExternalDepthRate();
BroadcastBoundaryState("tile_changed");
return true;
}
@@ -486,6 +537,7 @@ namespace FloodSWE.Networking
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
RefreshActiveTileBoundaryIds();
RecomputeExternalDepthRate();
BroadcastBoundaryState("tile_changed");
if (verboseDiagnostics && tileLoader != null)
{
Debug.Log($"SweServerRuntime: active tile set => {tileLoader.LastLoadSummary}");
@@ -538,6 +590,100 @@ namespace FloodSWE.Networking
Debug.LogWarning("SweServerRuntime: boundary manifest missing or invalid. Source/sink control disabled for this run.");
boundaryManifest = null;
lastForcingStatus = "disabled:manifest_missing_or_invalid";
boundaryProfiles.Clear();
return;
}
InitializeBoundaryProfilesFromManifest();
}
private void InitializeBoundaryProfilesFromManifest()
{
boundaryProfiles.Clear();
if (boundaryManifest == null)
{
return;
}
if (boundaryManifest.boundaries != null)
{
for (int i = 0; i < boundaryManifest.boundaries.Length; i++)
{
SweBoundaryDefinition boundary = boundaryManifest.boundaries[i];
if (boundary == null || boundary.id <= 0)
{
continue;
}
SweBoundaryProfile profile = boundary.default_state != null
? boundary.default_state.ToProfile(boundary.kind, boundary.id)
: new SweBoundaryProfile
{
boundaryKind = NormalizeBoundaryKind(boundary.kind),
boundaryId = boundary.id,
enabled = false,
waterLevelM = 0.0f,
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = 0.0f,
};
SetBoundaryProfileInternal(profile);
}
}
if (boundaryManifest.sources != null)
{
for (int i = 0; i < boundaryManifest.sources.Length; i++)
{
SweBoundarySource source = boundaryManifest.sources[i];
if (source == null || source.id <= 0)
{
continue;
}
string key = MakeBoundaryProfileKey("boundary_inflow", source.id);
if (!boundaryProfiles.ContainsKey(key))
{
SetBoundaryProfileInternal(new SweBoundaryProfile
{
boundaryKind = "boundary_inflow",
boundaryId = source.id,
enabled = false,
waterLevelM = 0.0f,
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = 0.0f,
});
}
}
}
if (boundaryManifest.sinks != null)
{
for (int i = 0; i < boundaryManifest.sinks.Length; i++)
{
SweBoundarySink sink = boundaryManifest.sinks[i];
if (sink == null || sink.id <= 0)
{
continue;
}
string key = MakeBoundaryProfileKey("sink", sink.id);
if (!boundaryProfiles.ContainsKey(key))
{
bool freeOutflow = sink.@params != null && sink.@params.IsMode("free_outflow");
SetBoundaryProfileInternal(new SweBoundaryProfile
{
boundaryKind = "sink",
boundaryId = sink.id,
enabled = freeOutflow,
waterLevelM = 0.0f,
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = 0.0f,
});
}
}
}
}
@@ -545,6 +691,10 @@ namespace FloodSWE.Networking
{
activeSourceIds = new HashSet<int>();
activeSinkIds = new HashSet<int>();
activeBoundaryInflowGhostCells.Clear();
activeSourceAreaCells.Clear();
activeSinkCells.Clear();
activeTileHasBoundaryCellGroups = false;
if (boundaryManifest == null)
{
@@ -559,35 +709,226 @@ namespace FloodSWE.Networking
return;
}
if (tile.source_ids != null)
{
for (int i = 0; i < tile.source_ids.Length; i++)
{
SweBoundaryTileIdRef entry = tile.source_ids[i];
if (entry != null && entry.id > 0)
{
activeSourceIds.Add(entry.id);
}
}
}
CollectIdsFromRefs(tile.source_ids, activeSourceIds);
CollectIdsFromRefs(tile.sink_ids, activeSinkIds);
CollectIdsFromRefs(tile.boundary_inflow_ids, activeSourceIds);
if (tile.sink_ids != null)
{
for (int i = 0; i < tile.sink_ids.Length; i++)
{
SweBoundaryTileIdRef entry = tile.sink_ids[i];
if (entry != null && entry.id > 0)
{
activeSinkIds.Add(entry.id);
}
}
}
CollectCellGroups(tile.boundary_cells, activeBoundaryInflowGhostCells, activeSourceIds);
CollectCellGroups(tile.source_area_cells, activeSourceAreaCells, activeSourceIds);
CollectCellGroups(tile.sink_cells, activeSinkCells, activeSinkIds);
Debug.Log($"SweServerRuntime: active tile sources={activeSourceIds.Count}, sinks={activeSinkIds.Count}");
activeTileHasBoundaryCellGroups =
activeBoundaryInflowGhostCells.Count > 0 ||
activeSourceAreaCells.Count > 0 ||
activeSinkCells.Count > 0;
Debug.Log(
$"SweServerRuntime: active tile inflowIds={activeSourceIds.Count}, sinkIds={activeSinkIds.Count}, " +
$"boundaryGhostGroups={activeBoundaryInflowGhostCells.Count}, sourceAreaGroups={activeSourceAreaCells.Count}, " +
$"sinkGroups={activeSinkCells.Count}");
}
private void RecomputeExternalDepthRate()
{
if (simulator == null)
{
return;
}
if (activeTileHasBoundaryCellGroups)
{
RecomputeBoundaryProfilesWithCellGroups();
return;
}
RecomputeLegacyBoundaryDepthRate();
}
private void RecomputeBoundaryProfilesWithCellGroups()
{
var ghostIndices = new List<int>(128);
var ghostLevels = new List<float>(128);
var ghostVelocities = new List<Vector2>(128);
foreach (var pair in activeBoundaryInflowGhostCells)
{
int boundaryId = pair.Key;
if (!TryResolveBoundaryProfile("boundary_inflow", boundaryId, out SweBoundaryProfile profile) || profile == null)
{
continue;
}
if (!profile.enabled)
{
continue;
}
int[] cells = pair.Value;
if (cells == null || cells.Length == 0)
{
continue;
}
float level = Mathf.Max(0.0f, profile.waterLevelM);
Vector2 velocity = new Vector2(profile.velocityUMps, profile.velocityVMps);
for (int i = 0; i < cells.Length; i++)
{
ghostIndices.Add(cells[i]);
ghostLevels.Add(level);
ghostVelocities.Add(velocity);
}
}
bool hasGhostForcing =
ghostIndices.Count > 0 &&
simulator.SetGhostBoundaryOverrides(ghostIndices.ToArray(), ghostLevels.ToArray(), ghostVelocities.ToArray());
if (!hasGhostForcing)
{
simulator.ClearGhostBoundaryOverrides();
}
int totalCells = simulator.gridRes * simulator.gridRes;
float[] perCell = new float[totalCells];
bool hasDepthForcing = false;
int forcedDepthCells = 0;
foreach (var pair in activeSourceAreaCells)
{
int boundaryId = pair.Key;
if (!TryResolveBoundaryProfile("source_area", boundaryId, out SweBoundaryProfile profile) || profile == null)
{
continue;
}
if (!profile.enabled)
{
continue;
}
float rate = profile.depthRateMps;
if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f)
{
rate = profile.waterLevelM * sourceDepthRatePerLevelMps;
}
if (rate <= 0.0f)
{
continue;
}
int[] cells = pair.Value;
if (cells == null)
{
continue;
}
for (int i = 0; i < cells.Length; i++)
{
int idx = cells[i];
if (idx < 0 || idx >= totalCells)
{
continue;
}
perCell[idx] += rate;
}
}
foreach (var pair in activeSinkCells)
{
int boundaryId = pair.Key;
float rate = 0.0f;
if (TryResolveBoundaryProfile("sink", boundaryId, out SweBoundaryProfile profile) && profile != null && profile.enabled)
{
rate = profile.depthRateMps;
if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f)
{
rate = -profile.waterLevelM * sinkDepthRatePerLevelMps;
}
}
if (Mathf.Abs(rate) <= 1e-9f && applyFreeOutflowByDefault && IsFreeOutflowSink(boundaryId))
{
rate = -Mathf.Max(0.0f, freeOutflowBaseLevel) * sinkDepthRatePerLevelMps;
}
if (rate > 0.0f)
{
rate = -rate;
}
if (Mathf.Abs(rate) <= 1e-9f)
{
continue;
}
int[] cells = pair.Value;
if (cells == null)
{
continue;
}
for (int i = 0; i < cells.Length; i++)
{
int idx = cells[i];
if (idx < 0 || idx >= totalCells)
{
continue;
}
perCell[idx] += rate;
}
}
for (int i = 0; i < perCell.Length; i++)
{
if (Mathf.Abs(perCell[i]) > 1e-9f)
{
hasDepthForcing = true;
forcedDepthCells++;
}
}
if (hasDepthForcing && simulator.SetExternalDepthRateMap(perCell))
{
simulator.SetExternalDepthRate(0.0f);
}
else
{
simulator.ClearExternalDepthRateMap();
simulator.SetExternalDepthRate(0.0f);
hasDepthForcing = false;
forcedDepthCells = 0;
}
if (!hasGhostForcing && !hasDepthForcing)
{
lastForcedCellCount = 0;
lastForcingStatus = "disabled:no_active_boundary_profiles";
return;
}
lastForcedCellCount = ghostIndices.Count + forcedDepthCells;
if (hasGhostForcing && hasDepthForcing)
{
lastForcingStatus = "ghost+localized";
}
else if (hasGhostForcing)
{
lastForcingStatus = "ghost_only";
}
else
{
lastForcingStatus = "localized";
}
if (verboseDiagnostics)
{
Debug.Log(
$"SweServerRuntime: forcing applied status={lastForcingStatus} ghost={ghostIndices.Count} depth={forcedDepthCells}");
}
}
private void RecomputeLegacyBoundaryDepthRate()
{
simulator.ClearGhostBoundaryOverrides();
var sourceRates = new Dictionary<int, float>();
foreach (int sourceId in activeSourceIds)
{
@@ -635,7 +976,7 @@ namespace FloodSWE.Networking
if (!hasBoundaryRates)
{
lastForcedCellCount = 0;
lastForcingStatus = "disabled:no_active_source_or_sink_levels";
lastForcingStatus = "disabled:no_active_source_or_sink_levels_legacy";
simulator.ClearExternalDepthRateMap();
simulator.SetExternalDepthRate(0.0f);
return;
@@ -687,13 +1028,13 @@ namespace FloodSWE.Networking
{
simulator.SetExternalDepthRate(0.0f);
lastForcedCellCount = forcedCellCount;
lastForcingStatus = "localized";
lastForcingStatus = "legacy_localized";
Debug.Log($"SweServerRuntime: applied localized boundary forcing cells={forcedCellCount}");
return;
}
lastForcedCellCount = 0;
lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed";
lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed_legacy";
}
else
{
@@ -707,7 +1048,7 @@ namespace FloodSWE.Networking
}
lastForcedCellCount = 0;
lastForcingStatus = "disabled:masks_unavailable";
lastForcingStatus = "disabled:masks_unavailable_legacy";
}
// Strict mode: no masks means no forcing. This prevents non-physical uniform flooding.
@@ -715,6 +1056,65 @@ namespace FloodSWE.Networking
simulator.SetExternalDepthRate(0.0f);
}
private bool TryResolveBoundaryProfile(string kind, int boundaryId, out SweBoundaryProfile profile)
{
profile = null;
if (boundaryId <= 0)
{
return false;
}
string normalizedKind = NormalizeBoundaryKind(kind);
string key = MakeBoundaryProfileKey(normalizedKind, boundaryId);
if (boundaryProfiles.TryGetValue(key, out profile) && profile != null)
{
return true;
}
if (boundaryManifest != null &&
boundaryManifest.TryGetBoundary(normalizedKind, boundaryId, out SweBoundaryDefinition boundary) &&
boundary != null)
{
profile = boundary.default_state != null
? boundary.default_state.ToProfile(normalizedKind, boundaryId)
: new SweBoundaryProfile
{
boundaryKind = normalizedKind,
boundaryId = boundaryId,
enabled = false,
waterLevelM = 0.0f,
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = 0.0f,
};
SetBoundaryProfileInternal(profile);
return true;
}
return false;
}
private bool IsFreeOutflowSink(int sinkId)
{
if (boundaryManifest == null || sinkId <= 0)
{
return false;
}
if (boundaryManifest.TryGetBoundary("sink", sinkId, out SweBoundaryDefinition boundary) &&
boundary != null &&
boundary.@params != null &&
boundary.@params.IsMode("free_outflow"))
{
return true;
}
return boundaryManifest.TryGetSink(sinkId, out SweBoundarySink sink) &&
sink != null &&
sink.@params != null &&
sink.@params.IsMode("free_outflow");
}
private static bool IsBorderCell(int flatIndex, int resolution)
{
if (resolution <= 1)
@@ -727,6 +1127,303 @@ namespace FloodSWE.Networking
return x == 0 || y == 0 || x == resolution - 1 || y == resolution - 1;
}
private static void CollectIdsFromRefs(SweBoundaryTileIdRef[] refs, HashSet<int> outIds)
{
if (refs == null || outIds == null)
{
return;
}
for (int i = 0; i < refs.Length; i++)
{
SweBoundaryTileIdRef entry = refs[i];
if (entry != null && entry.id > 0)
{
outIds.Add(entry.id);
}
}
}
private static void CollectCellGroups(
SweBoundaryTileCellGroup[] groups,
Dictionary<int, int[]> outGroups,
HashSet<int> outIds)
{
if (groups == null || outGroups == null || outIds == null)
{
return;
}
for (int i = 0; i < groups.Length; i++)
{
SweBoundaryTileCellGroup group = groups[i];
if (group == null || group.id <= 0 || group.cells == null || group.cells.Length == 0)
{
continue;
}
outIds.Add(group.id);
outGroups[group.id] = (int[])group.cells.Clone();
}
}
private void ApplyLegacySourceLevel(int sourceId, float sourceLevel)
{
if (sourceId <= 0)
{
return;
}
sourceLevels[sourceId] = sourceLevel;
var profile = new SweBoundaryProfile
{
boundaryKind = "boundary_inflow",
boundaryId = sourceId,
enabled = sourceLevel > 0.0f,
waterLevelM = Mathf.Max(0.0f, sourceLevel),
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = 0.0f,
};
SetBoundaryProfileInternal(profile);
RecomputeExternalDepthRate();
}
private void ApplyLegacySinkLevel(int sinkId, float sinkLevel)
{
if (sinkId <= 0)
{
return;
}
sinkLevels[sinkId] = sinkLevel;
var profile = new SweBoundaryProfile
{
boundaryKind = "sink",
boundaryId = sinkId,
enabled = sinkLevel > 0.0f,
waterLevelM = Mathf.Max(0.0f, sinkLevel),
velocityUMps = 0.0f,
velocityVMps = 0.0f,
depthRateMps = -Mathf.Max(0.0f, sinkLevel) * sinkDepthRatePerLevelMps,
};
SetBoundaryProfileInternal(profile);
RecomputeExternalDepthRate();
}
private bool TryApplyBoundaryProfileCommand(SweControlCommand command, out string result)
{
result = "invalid_boundary_profile";
if (command == null || command.boundaryId <= 0)
{
return false;
}
string kind = NormalizeBoundaryKind(command.boundaryKind);
var profile = new SweBoundaryProfile
{
boundaryKind = kind,
boundaryId = command.boundaryId,
enabled = command.enabled != 0,
waterLevelM = Mathf.Max(0.0f, command.waterLevelM),
velocityUMps = command.velocityUMps,
velocityVMps = command.velocityVMps,
depthRateMps = command.depthRateMps,
};
if (kind == "sink" && profile.depthRateMps > 0.0f)
{
profile.depthRateMps = -profile.depthRateMps;
}
SetBoundaryProfileInternal(profile);
RecomputeExternalDepthRate();
result = $"boundary profile set: {kind}:{profile.boundaryId}";
return true;
}
private bool TryApplyBoundaryBulkCommand(SweControlCommand command, out string result)
{
result = "invalid_boundary_bulk";
if (command == null || command.boundaries == null || command.boundaries.Length == 0)
{
return false;
}
if (command.replaceAll)
{
InitializeBoundaryProfilesFromManifest();
}
int applied = 0;
for (int i = 0; i < command.boundaries.Length; i++)
{
SweBoundaryProfile incoming = command.boundaries[i];
if (incoming == null || incoming.boundaryId <= 0)
{
continue;
}
string kind = NormalizeBoundaryKind(incoming.boundaryKind);
var profile = incoming.Clone();
profile.boundaryKind = kind;
if (kind == "sink" && profile.depthRateMps > 0.0f)
{
profile.depthRateMps = -profile.depthRateMps;
}
SetBoundaryProfileInternal(profile);
applied++;
}
if (applied <= 0)
{
return false;
}
RecomputeExternalDepthRate();
result = $"boundary bulk applied: {applied}";
return true;
}
private void AddStateSubscriber(IPEndPoint endpoint)
{
if (endpoint == null)
{
return;
}
stateSubscribers[MakeSubscriberKey(endpoint)] = endpoint;
}
private void RemoveStateSubscriber(IPEndPoint endpoint)
{
if (endpoint == null)
{
return;
}
stateSubscribers.Remove(MakeSubscriberKey(endpoint));
}
private void SendBoundaryState(IPEndPoint target, string messageType)
{
if (commandSocket == null || target == null)
{
return;
}
SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType);
byte[] payload = SweUdpProtocol.EncodeState(state);
try
{
commandSocket.Send(payload, payload.Length, target);
}
catch (Exception ex)
{
Debug.LogWarning($"SweServerRuntime: failed to send boundary state to {target}. {ex.Message}");
}
}
private void BroadcastBoundaryState(string messageType)
{
if (commandSocket == null || stateSubscribers.Count == 0)
{
return;
}
SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType);
byte[] payload = SweUdpProtocol.EncodeState(state);
var stale = new List<string>();
foreach (var pair in stateSubscribers)
{
try
{
commandSocket.Send(payload, payload.Length, pair.Value);
}
catch
{
stale.Add(pair.Key);
}
}
for (int i = 0; i < stale.Count; i++)
{
stateSubscribers.Remove(stale[i]);
}
}
private SweBoundaryStateMessage BuildBoundaryStateMessage(string messageType)
{
var profiles = new List<SweBoundaryProfile>(boundaryProfiles.Values);
profiles.Sort((a, b) =>
{
int kind = string.Compare(
a != null ? a.boundaryKind : string.Empty,
b != null ? b.boundaryKind : string.Empty,
StringComparison.OrdinalIgnoreCase);
if (kind != 0)
{
return kind;
}
int aid = a != null ? a.boundaryId : 0;
int bid = b != null ? b.boundaryId : 0;
return aid.CompareTo(bid);
});
var snapshot = new SweBoundaryProfile[profiles.Count];
for (int i = 0; i < profiles.Count; i++)
{
snapshot[i] = profiles[i] != null ? profiles[i].Clone() : null;
}
return new SweBoundaryStateMessage
{
messageType = messageType,
schemaVersion = boundaryManifest != null ? boundaryManifest.schema_version : 0,
lod = activeLod,
tileX = activeTileX,
tileY = activeTileY,
boundaries = snapshot,
};
}
private static string NormalizeBoundaryKind(string kind)
{
if (string.IsNullOrWhiteSpace(kind))
{
return "boundary_inflow";
}
string normalized = kind.Trim().ToLowerInvariant();
if (normalized == "source" || normalized == "boundary_source")
{
return "boundary_inflow";
}
if (normalized == "sourcearea" || normalized == "source-area")
{
return "source_area";
}
return normalized;
}
private static string MakeBoundaryProfileKey(string kind, int id)
{
return $"{NormalizeBoundaryKind(kind)}:{id}";
}
private void SetBoundaryProfileInternal(SweBoundaryProfile profile)
{
if (profile == null || profile.boundaryId <= 0)
{
return;
}
string kind = NormalizeBoundaryKind(profile.boundaryKind);
profile.boundaryKind = kind;
string key = MakeBoundaryProfileKey(kind, profile.boundaryId);
boundaryProfiles[key] = profile.Clone();
}
private void ApplyDefaultSourceIfConfigured()
{
if (!applyDefaultSourceOnStart)
@@ -748,7 +1445,7 @@ namespace FloodSWE.Networking
}
float clamped = Mathf.Max(0.0f, defaultSourceLevel);
sourceLevels[defaultSourceId] = clamped;
ApplyLegacySourceLevel(defaultSourceId, clamped);
Debug.Log($"SweServerRuntime: applied startup source id={defaultSourceId} level={clamped:0.###}");
}
@@ -773,6 +1470,7 @@ namespace FloodSWE.Networking
porosityValues = porosity,
sourceLevels = CloneDictionary(sourceLevels),
sinkLevels = CloneDictionary(sinkLevels),
boundaryProfiles = CloneBoundaryProfiles(boundaryProfiles),
};
checkpoints[checkpointName] = state;
@@ -803,6 +1501,7 @@ namespace FloodSWE.Networking
sourceLevels.Clear();
sinkLevels.Clear();
boundaryProfiles.Clear();
if (state.sourceLevels != null)
{
@@ -820,7 +1519,21 @@ namespace FloodSWE.Networking
}
}
if (state.boundaryProfiles != null)
{
for (int i = 0; i < state.boundaryProfiles.Length; i++)
{
SweBoundaryProfile profile = state.boundaryProfiles[i];
if (profile == null)
{
continue;
}
SetBoundaryProfileInternal(profile);
}
}
RecomputeExternalDepthRate();
BroadcastBoundaryState("checkpoint_restored");
return true;
}
@@ -840,6 +1553,22 @@ namespace FloodSWE.Networking
return clone;
}
private static SweBoundaryProfile[] CloneBoundaryProfiles(Dictionary<string, SweBoundaryProfile> source)
{
if (source == null || source.Count == 0)
{
return Array.Empty<SweBoundaryProfile>();
}
var outProfiles = new SweBoundaryProfile[source.Count];
int idx = 0;
foreach (var pair in source)
{
outProfiles[idx++] = pair.Value != null ? pair.Value.Clone() : null;
}
return outProfiles;
}
private static int ComputePacketInterval(float tickSeconds, float hz)
{
if (hz <= 0.0f || tickSeconds <= 0.0f)
@@ -1003,6 +1732,7 @@ namespace FloodSWE.Networking
public float[] porosityValues;
public Dictionary<int, float> sourceLevels;
public Dictionary<int, float> sinkLevels;
public SweBoundaryProfile[] boundaryProfiles;
}
}
}

View File

@@ -8,11 +8,19 @@ namespace FloodSWE.Networking
public sealed class SweBoundaryManifest
{
public int schema_version;
public string boundary_inflow_mask_dir;
public string source_area_mask_dir;
public string sink_mask_dir;
public string boundary_inflow_params_toml;
public string source_area_params_toml;
public string sink_params_toml;
public SweBoundarySource[] sources;
public SweBoundarySink[] sinks;
public SweBoundaryDefinition[] boundaries;
public SweBoundaryTile[] tiles;
[NonSerialized] private Dictionary<string, SweBoundaryTile> tileLookup;
[NonSerialized] private Dictionary<string, SweBoundaryDefinition> boundaryLookup;
[NonSerialized] private Dictionary<int, SweBoundarySource> sourceLookup;
[NonSerialized] private Dictionary<int, SweBoundarySink> sinkLookup;
@@ -64,6 +72,17 @@ namespace FloodSWE.Networking
return sourceLookup != null && sourceLookup.TryGetValue(id, out source);
}
public bool TryGetBoundary(string kind, int id, out SweBoundaryDefinition boundary)
{
boundary = null;
if (boundaryLookup == null)
{
BuildLookup();
}
return boundaryLookup != null && boundaryLookup.TryGetValue(MakeBoundaryKey(kind, id), out boundary);
}
public bool TryGetSink(int id, out SweBoundarySink sink)
{
sink = null;
@@ -94,6 +113,7 @@ namespace FloodSWE.Networking
private void BuildLookup()
{
tileLookup = new Dictionary<string, SweBoundaryTile>(StringComparer.OrdinalIgnoreCase);
boundaryLookup = new Dictionary<string, SweBoundaryDefinition>(StringComparer.OrdinalIgnoreCase);
sourceLookup = new Dictionary<int, SweBoundarySource>();
sinkLookup = new Dictionary<int, SweBoundarySink>();
if (tiles == null)
@@ -115,6 +135,25 @@ namespace FloodSWE.Networking
}
}
if (boundaries != null && boundaries.Length > 0)
{
for (int i = 0; i < boundaries.Length; i++)
{
SweBoundaryDefinition boundary = boundaries[i];
if (boundary == null || boundary.id <= 0)
{
continue;
}
if (boundary.default_state == null)
{
boundary.default_state = SweBoundaryDefaultState.DefaultFor(boundary.kind, boundary.@params);
}
boundaryLookup[MakeBoundaryKey(boundary.kind, boundary.id)] = boundary;
}
}
if (sources != null)
{
for (int i = 0; i < sources.Length; i++)
@@ -123,6 +162,19 @@ namespace FloodSWE.Networking
if (source != null)
{
sourceLookup[source.id] = source;
string key = MakeBoundaryKey("boundary_inflow", source.id);
if (!boundaryLookup.ContainsKey(key))
{
boundaryLookup[key] = new SweBoundaryDefinition
{
kind = "boundary_inflow",
id = source.id,
tile_count = source.tile_count,
total_pixels = source.total_pixels,
@params = source.@params,
default_state = SweBoundaryDefaultState.DefaultFor("boundary_inflow", source.@params),
};
}
}
}
}
@@ -135,6 +187,19 @@ namespace FloodSWE.Networking
if (sink != null)
{
sinkLookup[sink.id] = sink;
string key = MakeBoundaryKey("sink", sink.id);
if (!boundaryLookup.ContainsKey(key))
{
boundaryLookup[key] = new SweBoundaryDefinition
{
kind = "sink",
id = sink.id,
tile_count = sink.tile_count,
total_pixels = sink.total_pixels,
@params = sink.@params,
default_state = SweBoundaryDefaultState.DefaultFor("sink", sink.@params),
};
}
}
}
}
@@ -144,6 +209,16 @@ namespace FloodSWE.Networking
{
return $"{lod}|{tileX}|{tileY}";
}
private static string MakeBoundaryKey(string kind, int id)
{
string normalized = string.IsNullOrWhiteSpace(kind) ? "boundary_inflow" : kind.Trim().ToLowerInvariant();
if (normalized == "source")
{
normalized = "boundary_inflow";
}
return $"{normalized}:{id}";
}
}
[Serializable]
@@ -170,8 +245,30 @@ namespace FloodSWE.Networking
public string lod;
public int tile_x;
public int tile_y;
public float tile_size_m;
public int resolution;
public float[] bounds;
public SweBoundaryTileIdRef[] source_ids;
public SweBoundaryTileIdRef[] sink_ids;
public SweBoundaryTileIdRef[] boundary_inflow_ids;
public SweBoundaryTileIdRef[] source_area_ids;
public SweBoundaryTileCellGroup[] boundary_cells;
public SweBoundaryTileCellGroup[] source_area_cells;
public SweBoundaryTileCellGroup[] sink_cells;
public string source_id_path;
public string sink_id_path;
public string boundary_inflow_id_path;
public string source_area_id_path;
public bool HasCellGroups
{
get
{
return (boundary_cells != null && boundary_cells.Length > 0) ||
(source_area_cells != null && source_area_cells.Length > 0) ||
(sink_cells != null && sink_cells.Length > 0);
}
}
}
[Serializable]
@@ -195,4 +292,62 @@ namespace FloodSWE.Networking
public int id;
public int pixels;
}
[Serializable]
public sealed class SweBoundaryTileCellGroup
{
public int id;
public int count;
public int[] cells;
}
[Serializable]
public sealed class SweBoundaryDefinition
{
public string kind;
public int id;
public int tile_count;
public int total_pixels;
public SweBoundaryParams @params;
public SweBoundaryDefaultState default_state;
}
[Serializable]
public sealed class SweBoundaryDefaultState
{
public bool enabled;
public float water_level_m;
public float velocity_u_mps;
public float velocity_v_mps;
public float depth_rate_mps;
public static SweBoundaryDefaultState DefaultFor(string kind, SweBoundaryParams parameters)
{
bool sinkFreeOutflow = string.Equals(kind, "sink", StringComparison.OrdinalIgnoreCase) &&
parameters != null &&
parameters.IsMode("free_outflow");
return new SweBoundaryDefaultState
{
enabled = sinkFreeOutflow,
water_level_m = 0.0f,
velocity_u_mps = 0.0f,
velocity_v_mps = 0.0f,
depth_rate_mps = 0.0f,
};
}
public SweBoundaryProfile ToProfile(string kind, int id)
{
return new SweBoundaryProfile
{
boundaryKind = kind,
boundaryId = id,
enabled = enabled,
waterLevelM = water_level_m,
velocityUMps = velocity_u_mps,
velocityVMps = velocity_v_mps,
depthRateMps = depth_rate_mps,
};
}
}
}

View File

@@ -12,6 +12,7 @@ namespace FloodSWE.Networking
public const byte FrameType = 1;
public const byte ControlType = 2;
public const byte AckType = 3;
public const byte StateType = 4;
public static byte[] EncodeFrame(HeightmapPacket packet)
{
@@ -156,6 +157,36 @@ namespace FloodSWE.Networking
return false;
}
}
public static byte[] EncodeState(SweBoundaryStateMessage message)
{
string json = JsonUtility.ToJson(message);
byte[] utf8 = Encoding.UTF8.GetBytes(json);
byte[] payload = new byte[utf8.Length + 1];
payload[0] = StateType;
Buffer.BlockCopy(utf8, 0, payload, 1, utf8.Length);
return payload;
}
public static bool TryDecodeState(byte[] data, out SweBoundaryStateMessage message)
{
message = null;
if (data == null || data.Length < 2 || data[0] != StateType)
{
return false;
}
try
{
string json = Encoding.UTF8.GetString(data, 1, data.Length - 1);
message = JsonUtility.FromJson<SweBoundaryStateMessage>(json);
return message != null;
}
catch
{
return false;
}
}
}
[Serializable]
@@ -174,5 +205,53 @@ namespace FloodSWE.Networking
public float v;
public float radius;
public float porosity;
public string boundaryKind;
public int boundaryId;
public int enabled;
public float waterLevelM;
public float velocityUMps;
public float velocityVMps;
public float depthRateMps;
public bool replaceAll;
public bool subscribe;
public SweBoundaryProfile[] boundaries;
}
[Serializable]
public sealed class SweBoundaryProfile
{
public string boundaryKind;
public int boundaryId;
public bool enabled;
public float waterLevelM;
public float velocityUMps;
public float velocityVMps;
public float depthRateMps;
public SweBoundaryProfile Clone()
{
return new SweBoundaryProfile
{
boundaryKind = boundaryKind,
boundaryId = boundaryId,
enabled = enabled,
waterLevelM = waterLevelM,
velocityUMps = velocityUMps,
velocityVMps = velocityVMps,
depthRateMps = depthRateMps,
};
}
}
[Serializable]
public sealed class SweBoundaryStateMessage
{
public string messageType;
public int schemaVersion;
public string lod;
public int tileX;
public int tileY;
public SweBoundaryProfile[] boundaries;
}
}