Compare commits

...

26 Commits

Author SHA1 Message Date
Pieter-Jan Briers
c2a1521c95 Version: 214.2.2 2024-08-11 19:32:36 +02:00
Pieter-Jan Briers
a26b48414b Compile compat fixes
(cherry picked from commit 025d90d281)
(cherry picked from commit 799702b814)
(cherry picked from commit 4600ee8e5788891f1b610e2d5141fb4e1228d323)
2024-08-11 19:32:36 +02:00
Pieter-Jan Briers
614a03036b Version: 214.2.1 2024-08-11 17:56:10 +02:00
Pieter-Jan Briers
8c8a3c0e17 Security updates (#5353)
* Fix security bug in WritableDirProvider.OpenOsWindow()

Reported by @NarryG and @nyeogmi

* Sandbox updates

* Update ImageSharp again

(cherry picked from commit 7d778248ee)
(cherry picked from commit f66cda74e95619ddba2221bda644bf4394619805)
(cherry picked from commit db8ba83866c523e08e4fba0b80cd954f4f190613)
2024-08-11 17:56:10 +02:00
ElectroJr
b1f9d011ce Version: 214.2.0 2024-03-16 16:17:59 -04:00
Leon Friedrich
a2d0504368 Replace PVS dictionaries with memory magic (#4795)
Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
2024-03-17 06:57:13 +11:00
metalgearsloth
7aa951ca48 Add undetachable PVS flag (#4889)
Useful in some rare cases, mainly for grid-related activities.
Specifically:
- Audio entity where we never want it detached.
- FTL previs effects to show impending squish.
2024-03-16 14:58:08 +11:00
metalgearsloth
75a80b7a8a Fix tooltips underflowing left side of screen (#4952)
* Fix tooltips underflowing left side of screen

If the tooltip is so large it would clip the right side then it would underflow completely off-screen. This just clamps it instead.

* Better

* rubb
2024-03-16 14:45:17 +11:00
metalgearsloth
69706b0257 Fix global audio (#4964)
* Fix global audio

* Better
2024-03-16 11:59:57 +11:00
Pieter-Jan Briers
10b191dff8 Version: 214.1.1 2024-03-16 01:13:47 +01:00
Pieter-Jan Briers
92ab3fb64b Fix connection denials always redialling
Bug caused by changes to connection denial.

Fixes #4963
2024-03-16 01:13:34 +01:00
metalgearsloth
92a0c14383 Version: 214.1.0 2024-03-15 20:20:20 +11:00
metalgearsloth
5aaf6d0994 Fix VV for entity prototypes (#4956)
* Fix VV for entity prototypes

* Fix ProtoId
2024-03-15 20:16:09 +11:00
metalgearsloth
15f4da5e4b Audio limit fix (#4962)
I screm. See https://github.com/space-wizards/RobustToolbox/issues/4961
2024-03-15 20:14:49 +11:00
Leon Friedrich
a528e87f3d Add pvs_override_info command (#4958) 2024-03-15 14:32:23 +11:00
Pieter-Jan Briers
4af67b1394 Version: 214.0.0 2024-03-14 20:42:17 +01:00
metalgearsloth
e8de9b98d3 Add basic audio limits (#4921)
* Add basic audio limits

* RN

---------

Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
2024-03-14 11:54:43 +01:00
Pieter-Jan Briers
a0ffeff4e5 Release notes for last commit 2024-03-14 08:10:54 +01:00
Pieter-Jan Briers
07654564f3 TextEdit fixes
Fixed being able to position the cursor vertically if placeholder text was visible and multi-line. This is because the code was using line break info for the place holder. On top of not being correct behavior, this caused further exceptions since the cursor would get outside the editable text rope.

Fixed index exception if you try to move left in an empty text edit.

Has regression tests.

Fixes #4957, fixes #4953
2024-03-14 08:09:28 +01:00
Pieter-Jan Briers
7fbf8d05eb Add ability to add structured deny data to NetConnectingArgs. (#4487)
* Add ability to add structured deny data to NetConnectingArgs.

Builds on the (horrifying) NetStructuredDisconnectMessages so that content can do more stuff.

To be used by SS14 to throttle people when they try to connect to a full server.

* Completely rewrite NetStructuredDisconnectMessages

So this class was a mess, and it was so bad it wasn't usable from content! System.Text.Json isn't sandbox safe (and I don't want to look into that right now), so the previous API surface of "pass the JsonNode around everywhere" just didn't work at all for content.

I decided the easiest solution would be to completely rewrite the entire thing to be a layer over a Dictionary<string, object> instead. This warranted a complete rewrite of the API, which should be fine as I doubt anybody was using it anyways.

Also, fully tested.
2024-03-14 07:27:22 +01:00
ShadowCommander
c12971cb9b Add decimal variable to Range Control rounding (#4954)
* Add decimal variable to Range Control rounding

* Remove unnecessary virtual and add ViewVariables
2024-03-13 00:43:27 +01:00
metalgearsloth
2b6381c332 Version: 213.0.0 2024-03-11 14:36:45 +11:00
TemporalOroboros
8149a3aaad Removes Obsolete BaseContainer methods. (#4843) 2024-03-11 14:35:31 +11:00
Kot
4b39bf1f2d Check entity for existence before drawing it in the SpriteView (#4886)
* Check entity for existence before drawing it in the SpriteView

* Slightly refactor ResolveEntity to be more straightforward
2024-03-11 14:34:44 +11:00
metalgearsloth
53394fff44 Add GetEntitiesInRange for sets (#4951)
* Add GetEntitiesInRange for sets

Need it for an old method.

* rn

* Fix SO
2024-03-11 13:43:13 +11:00
metalgearsloth
4bed20e070 Add RaiseSharedEvent (#4950)
Used in some rare cases on content (popups + pickup prediction). I was too lazy to make system proxy methods because it's very infrequent.
2024-03-10 19:33:45 +01:00
57 changed files with 2174 additions and 380 deletions

View File

@@ -33,10 +33,10 @@ jobs:
mkdir "release/${{ steps.parse_version.outputs.version }}"
mv release/*.zip "release/${{ steps.parse_version.outputs.version }}"
- name: Upload files to centcomm
- name: Upload files to Suns
uses: appleboy/scp-action@master
with:
host: centcomm.spacestation14.io
host: suns.spacestation14.com
username: robust-build-push
key: ${{ secrets.CENTCOMM_ROBUST_BUILDS_PUSH_KEY }}
source: "release/${{ steps.parse_version.outputs.version }}"
@@ -46,7 +46,7 @@ jobs:
- name: Update manifest JSON
uses: appleboy/ssh-action@master
with:
host: centcomm.spacestation14.io
host: suns.spacestation14.com
username: robust-build-push
key: ${{ secrets.CENTCOMM_ROBUST_BUILDS_PUSH_KEY }}
script: /home/robust-build-push/push.ps1 ${{ steps.parse_version.outputs.version }}

View File

@@ -1,4 +1,14 @@
<Project>
<PropertyGroup>
<!--
We actually set ManagePackageVersionsCentrally manually in another import file.
Since .NET SDK 8.0.300, ManagePackageVersionsCentrally is automatically set if Directory.Packages.props exists.
https://github.com/NuGet/NuGet.Client/pull/5572
We actively negate this here, as we have some packages in tree we don't want such automatic behavior for.
We use Directory.Build.props to get copy the state *after* our MSBuild config but before Nuget's config.
-->
<ManagePackageVersionsCentrally />
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.13.12" />
<PackageVersion Include="DiscordRichPresence" Version="1.2.1.24" />
@@ -45,7 +55,7 @@
<PackageVersion Include="Serilog" Version="3.1.1" />
<PackageVersion Include="Serilog.Sinks.Loki" Version="4.0.0-beta3" />
<PackageVersion Include="SharpZstd.Interop" Version="1.5.2-beta2" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.3" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageVersion Include="SpaceWizards.HttpListener" Version="0.1.0" />
<PackageVersion Include="SpaceWizards.NFluidsynth" Version="0.1.1" />
<PackageVersion Include="SpaceWizards.SharpFont" Version="1.0.2" />

View File

@@ -1,4 +1,4 @@
<Project>
<!-- This file automatically reset by Tools/version.py -->
<!-- This file automatically reset by Tools/version.py -->

View File

@@ -54,6 +54,80 @@ END TEMPLATE-->
*None yet*
## 214.2.2
## 214.2.1
## 214.2.0
### New features
* Added a `Undetachable` entity metadata flag, which stops the client from moving an entity to nullspace when it moves out of PVS range.
### Bugfixes
* Fix tooltips not clamping to the left side of the viewport.
* Fix global audio property not being properly set.
### Internal
* The server game state / PVS code has been rewritten. It should be somewhat faster now, albeit at the cost of using more memory. The current engine version may be unstable.
## 214.1.1
### Bugfixes
* Fixed connection denial always causing redial.
## 214.1.0
### New features
* Added the `pvs_override_info` command for debugging PVS overrides.
### Bugfixes
* Fix VV for prototype structs.
* Fix audio limits for clientside audio.
## 214.0.0
### Breaking changes
* `NetStructuredDisconnectMessages` has received a complete overhaul and has been moved to `NetDisconnectMessage`. The API is no longer designed such that consumers must pass around JSON nodes, as they are not in sandbox (and clunky).
### New features
* Add a basic default concurrent audio limit of 16 for a single filepath to avoid overflowing audio sources.
* `NetConnectingArgs.Deny()` can now pass along structured data that will be received by the client.
### Bugfixes
* Fixed cursor position bugs when an empty `TextEdit` has a multi-line place holder.
* Fixed empty `TextEdit` throwing exception if cursor is moved left.
## 213.0.0
### Breaking changes
* Remove obsoleted BaseContainer methods.
### New features
* Add EntityManager.RaiseSharedEvent where the event won't go to the attached client but will be predicted locally on their end.
* Add GetEntitiesInRange override that takes in EntityCoordinates and an EntityUid hashset.
### Bugfixes
* Check if a sprite entity is deleted before drawing in SpriteView.
## 212.2.0
### New features

View File

@@ -566,3 +566,9 @@ cmd-reloadtiletextures-help = Usage: reloadtiletextures
cmd-audio_length-desc = Shows the length of an audio file
cmd-audio_length-help = Usage: audio_length { cmd-audio_length-arg-file-name }
cmd-audio_length-arg-file-name = <file name>
## PVS
cmd-pvs-override-info-desc = Prints information about any PVS overrides associated with an entity.
cmd-pvs-override-info-empty = Entity {$nuid} has no PVS overrides.
cmd-pvs-override-info-global = Entity {$nuid} has a global override.
cmd-pvs-override-info-clients = Entity {$nuid} has a session override for {$clients}.

View File

@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Robust.Shared;
using Robust.Shared.GameObjects;
namespace Robust.Client.Audio;
public sealed partial class AudioSystem
{
/*
* Handles limiting concurrent sounds for audio to avoid blowing the source budget on one sound getting spammed.
*/
private readonly Dictionary<string, int> _playingCount = new();
private int _maxConcurrent;
private void InitializeLimit()
{
Subs.CVar(CfgManager, CVars.AudioDefaultConcurrent, SetConcurrentLimit, true);
}
private void SetConcurrentLimit(int obj)
{
_maxConcurrent = obj;
}
private bool TryAudioLimit(string sound)
{
if (string.IsNullOrEmpty(sound))
return true;
ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(_playingCount, sound, out _);
if (count >= _maxConcurrent)
return false;
count++;
return true;
}
private void RemoveAudioLimit(string sound)
{
if (!_playingCount.TryGetValue(sound, out var count))
return;
count--;
if (count <= 0)
{
_playingCount.Remove(sound);
return;
}
_playingCount[sound] = count;
}
}

View File

@@ -41,6 +41,7 @@ public sealed partial class AudioSystem : SharedAudioSystem
[Dependency] private readonly IParallelManager _parMan = default!;
[Dependency] private readonly IRuntimeLog _runtimeLog = default!;
[Dependency] private readonly IAudioInternal _audio = default!;
[Dependency] private readonly MetaDataSystem _metadata = default!;
[Dependency] private readonly SharedTransformSystem _xformSys = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
@@ -51,6 +52,7 @@ public sealed partial class AudioSystem : SharedAudioSystem
private EntityUid? _listenerGrid;
private UpdateAudioJob _updateAudioJob;
private EntityQuery<PhysicsComponent> _physicsQuery;
private float _maxRayLength;
@@ -108,6 +110,7 @@ public sealed partial class AudioSystem : SharedAudioSystem
Subs.CVar(CfgManager, CVars.AudioAttenuation, OnAudioAttenuation, true);
Subs.CVar(CfgManager, CVars.AudioRaycastLength, OnRaycastLengthChanged, true);
InitializeLimit();
}
private void OnAudioState(EntityUid uid, AudioComponent component, ref AfterAutoHandleStateEvent args)
@@ -163,26 +166,37 @@ public sealed partial class AudioSystem : SharedAudioSystem
return;
}
SetupSource(component, audioResource);
SetupSource((uid, component), audioResource);
component.Loaded = true;
}
private void SetupSource(AudioComponent component, AudioResource audioResource, TimeSpan? length = null)
private void SetupSource(Entity<AudioComponent> entity, AudioResource audioResource, TimeSpan? length = null)
{
var source = _audio.CreateAudioSource(audioResource);
if (source == null)
var component = entity.Comp;
if (TryAudioLimit(component.FileName))
{
Log.Error($"Error creating audio source for {audioResource}");
DebugTools.Assert(false);
source = component.Source;
var newSource = _audio.CreateAudioSource(audioResource);
if (newSource == null)
{
Log.Error($"Error creating audio source for {audioResource}");
DebugTools.Assert(false);
}
else
{
component.Source = newSource;
}
}
component.Source = source;
if ((component.Flags & AudioFlags.GridAudio) != 0x0)
{
_metadata.SetFlag(entity.Owner, MetaDataFlags.Undetachable, true);
}
// Need to set all initial data for first frame.
ApplyAudioParams(component.Params, component);
source.Global = component.Global;
component.Source.Global = component.Global;
// Don't play until first frame so occlusion etc. are correct.
component.Gain = 0f;
@@ -202,6 +216,8 @@ public sealed partial class AudioSystem : SharedAudioSystem
{
// Breaks with prediction?
component.Source.Dispose();
RemoveAudioLimit(component.FileName);
}
private void OnAudioAttenuation(int obj)
@@ -576,13 +592,13 @@ public sealed partial class AudioSystem : SharedAudioSystem
return PlayGlobal(filename, audioParams);
}
public override void LoadStream<T>(AudioComponent component, T stream)
public override void LoadStream<T>(Entity<AudioComponent> entity, T stream)
{
if (stream is AudioStream audioStream)
{
TryGetAudio(audioStream, out var audio);
SetupSource(component, audio!, audioStream.Length);
component.Loaded = true;
SetupSource(entity, audio!, audioStream.Length);
entity.Comp.Loaded = true;
}
}
@@ -621,7 +637,7 @@ public sealed partial class AudioSystem : SharedAudioSystem
var audioP = audioParams ?? AudioParams.Default;
var entity = EntityManager.CreateEntityUninitialized("Audio", MapCoordinates.Nullspace);
var comp = SetupAudio(entity, null, audioP, stream.Length);
LoadStream(comp, stream);
LoadStream((entity, comp), stream);
EntityManager.InitializeAndStartEntity(entity);
var source = comp.Source;

View File

@@ -11,7 +11,7 @@ namespace Robust.Client.Console.Commands
public override void Execute(IConsoleShell shell, string argStr, string[] args)
{
var type = Type.GetType(args[0]);
var type = GetType(args[0]);
if (type == null)
{
@@ -25,6 +25,17 @@ namespace Robust.Client.Console.Commands
shell.WriteLine(sig);
}
}
private Type? GetType(string name)
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (assembly.GetType(name) is { } type)
return type;
}
return null;
}
}
#endif
}

View File

@@ -165,7 +165,7 @@ Suspendisse hendrerit blandit urna ut laoreet. Suspendisse ac elit at erat males
{
var textEdit = new TextEdit
{
Placeholder = new Rope.Leaf("You deleted the lipsum OwO")
Placeholder = new Rope.Leaf("You deleted the lipsum\nOwO")
};
TabContainer.SetTabTitle(textEdit, "TextEdit");

View File

@@ -8,6 +8,7 @@ using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Network;
using Robust.Shared.Network.Messages;
using Robust.Shared.Player;
using Robust.Shared.Replays;
using Robust.Shared.Utility;
@@ -134,6 +135,24 @@ namespace Robust.Client.GameObjects
EventBus.RaiseEvent(EventSource.Local, new EntitySessionMessage<T>(eventArgs, msg));
}
/// <inheritdoc />
public override void RaiseSharedEvent<T>(T message, EntityUid? user = null)
{
if (user == null || user != _playerManager.LocalEntity || !_gameTiming.IsFirstTimePredicted)
return;
EventBus.RaiseEvent(EventSource.Local, ref message);
}
/// <inheritdoc />
public override void RaiseSharedEvent<T>(T message, ICommonSession? user = null)
{
if (user == null || user != _playerManager.LocalSession || !_gameTiming.IsFirstTimePredicted)
return;
EventBus.RaiseEvent(EventSource.Local, ref message);
}
#region IEntityNetworkManager impl
public override IEntityNetworkManager EntityNetManager => this;

View File

@@ -1120,7 +1120,7 @@ namespace Robust.Client.GameStates
continue;
}
if ((meta.Flags & MetaDataFlags.Detached) != 0)
if ((meta.Flags & (MetaDataFlags.Detached | MetaDataFlags.Undetachable)) != 0)
continue;
if (lastStateApplied.HasValue)

View File

@@ -13,6 +13,7 @@ namespace Robust.Client.UserInterface.Controls
private float _value;
private float _page;
private bool _rounded;
private int _roundingDecimals = 0;
public event Action<Range>? OnValueChanged;
@@ -86,6 +87,17 @@ namespace Robust.Client.UserInterface.Controls
}
}
[ViewVariables]
public int RoundingDecimals
{
get => _roundingDecimals;
set
{
_roundingDecimals = value;
_ensureValueClamped();
}
}
public virtual void SetValueWithoutEvent(float newValue)
{
newValue = ClampValue(newValue);
@@ -107,7 +119,7 @@ namespace Robust.Client.UserInterface.Controls
{
if (_rounded)
{
value = MathF.Round(value);
value = MathF.Round(value, _roundingDecimals);
}
return MathHelper.Clamp(value, _minValue, _maxValue-_page);
}

View File

@@ -16,7 +16,7 @@ namespace Robust.Client.UserInterface.Controls
{
private SpriteSystem? _sprite;
private SharedTransformSystem? _transform;
IEntityManager _entMan;
private readonly IEntityManager _entMan;
[ViewVariables]
public SpriteComponent? Sprite => Entity?.Comp1;
@@ -143,6 +143,8 @@ namespace Robust.Client.UserInterface.Controls
if (netEnt == NetEnt)
return;
// The Entity is getting set later in the ResolveEntity method
// because the client may not have received it yet.
Entity = null;
NetEnt = netEnt;
}
@@ -256,28 +258,19 @@ namespace Robust.Client.UserInterface.Controls
[NotNullWhen(true)] out SpriteComponent? sprite,
[NotNullWhen(true)] out TransformComponent? xform)
{
if (NetEnt != null && Entity == null && _entMan.TryGetEntity(NetEnt, out var ent))
SetEntity(ent);
if (Entity != null)
{
(uid, sprite, xform) = Entity.Value;
return true;
return !_entMan.Deleted(uid);
}
sprite = null;
xform = null;
uid = default;
if (NetEnt == null)
return false;
if (!_entMan.TryGetEntity(NetEnt, out var ent))
return false;
SetEntity(ent);
if (Entity == null)
return false;
(uid, sprite, xform) = Entity.Value;
return true;
return false;
}
}
}

View File

@@ -576,7 +576,7 @@ public sealed class TextEdit : Control
var newPos = CursorShiftedLeft();
// Explicit newlines work kinda funny with bias, so keep it at top there.
var bias = Rope.Index(TextRope, newPos) == '\n'
var bias = _cursorPosition.Index == TextLength || Rope.Index(TextRope, newPos) == '\n'
? LineBreakBias.Top
: LineBreakBias.Bottom;
@@ -940,6 +940,13 @@ public sealed class TextEdit : Control
private CursorPos GetIndexAtHorizontalPos(int line, float horizontalPos)
{
// If the placeholder is visible, this function does not return correct results because it looks at TextRope,
// but _lineBreaks is configured for the display rope.
// Bail out early in this case, the function is not currently used in any situation in any location
// where something else is desired if the placeholder is visible.
if (IsPlaceholderVisible)
return default;
var contentBox = PixelSizeBox;
var font = GetFont();
var uiScale = UIScale;

View File

@@ -1,4 +1,5 @@
using System.Numerics;
using System;
using System.Numerics;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Maths;
@@ -41,20 +42,16 @@ namespace Robust.Client.UserInterface
tooltip.Measure(Vector2Helpers.Infinity);
var combinedMinSize = tooltip.DesiredSize;
LayoutContainer.SetPosition(tooltip, new Vector2(screenPosition.X, screenPosition.Y - combinedMinSize.Y));
// If it overflows right bounds then just place left on the edge.
var right = MathF.Min(screenPosition.X + combinedMinSize.X, screenBounds.X);
var right = tooltip.Position.X + combinedMinSize.X;
var top = tooltip.Position.Y;
// However, better to clamp the end of the tooltip instead of the start.
var left = MathF.Max(0f, right - combinedMinSize.X);
if (right > screenBounds.X)
{
LayoutContainer.SetPosition(tooltip, new(screenPosition.X - combinedMinSize.X, tooltip.Position.Y));
}
var bottom = MathF.Min(screenPosition.Y, screenBounds.Y);
var top = MathF.Max(0f, bottom - combinedMinSize.Y);
if (top < 0f)
{
LayoutContainer.SetPosition(tooltip, new(tooltip.Position.X, 0f));
}
LayoutContainer.SetPosition(tooltip, new Vector2(left, top));
}
}
}

View File

@@ -126,6 +126,22 @@ namespace Robust.Client.ViewVariables
return new VVPropEditorString();
}
if (type == typeof(EntProtoId) ||
type == typeof(EntProtoId?))
{
return new VVPropEditorEntProtoId();
}
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ProtoId<>))
{
var editor =
(VVPropEditor)Activator.CreateInstance(
typeof(VVPropEditorProtoId<>).MakeGenericType(type.GenericTypeArguments[0]))!;
IoCManager.InjectDependencies(editor);
return editor;
}
if (typeof(IPrototype).IsAssignableFrom(type) || typeof(ViewVariablesBlobMembers.PrototypeReferenceToken).IsAssignableFrom(type))
{
return (VVPropEditor)Activator.CreateInstance(typeof(VVPropEditorIPrototype<>).MakeGenericType(type))!;

View File

@@ -0,0 +1,28 @@
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Prototypes;
namespace Robust.Client.ViewVariables.Editors;
internal sealed class VVPropEditorEntProtoId : VVPropEditor
{
protected override Control MakeUI(object? value)
{
var lineEdit = new LineEdit
{
Text = (EntProtoId) (value ?? ""),
Editable = !ReadOnly,
HorizontalExpand = true,
};
if (!ReadOnly)
{
lineEdit.OnTextEntered += e =>
{
ValueChanged((EntProtoId) e.Text);
};
}
return lineEdit;
}
}

View File

@@ -0,0 +1,38 @@
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.IoC;
using Robust.Shared.Prototypes;
namespace Robust.Client.ViewVariables.Editors;
internal sealed class VVPropEditorProtoId<T> : VVPropEditor where T : class, IPrototype
{
[Dependency] private readonly IPrototypeManager _protoManager = default!;
protected override Control MakeUI(object? value)
{
var lineEdit = new LineEdit
{
Text = (ProtoId<T>) (value ?? ""),
Editable = !ReadOnly,
HorizontalExpand = true,
};
if (!ReadOnly)
{
lineEdit.OnTextEntered += e =>
{
var id = (ProtoId<T>)e.Text;
if (!_protoManager.HasIndex(id))
{
return;
}
ValueChanged(id);
};
}
return lineEdit;
}
}

View File

@@ -238,7 +238,8 @@ public sealed partial class AudioSystem : SharedAudioSystem
}
}
public override void LoadStream<T>(AudioComponent component, T stream)
public override void LoadStream<T>(Entity<AudioComponent> entity, T stream)
{
// TODO: Yeah remove this...
}
}

View File

@@ -653,8 +653,8 @@ namespace Robust.Server
_playerManager.PlayerStatusChanged -= OnPlayerStatusChanged;
// shut down networking, kicking all players.
var shutdownReasonWithRedial = NetStructuredDisconnectMessages.Encode($"Server shutting down: {_shutdownReason}", true);
_network.Shutdown(shutdownReasonWithRedial);
var shutdownReasonWithRedial = new NetDisconnectMessage($"Server shutting down: {_shutdownReason}", true);
_network.Shutdown(shutdownReasonWithRedial.Encode());
// shutdown entities
_entityManager.Cleanup();

View File

@@ -1,4 +1,5 @@
using System;
using Robust.Server.GameStates;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.ViewVariables;
@@ -7,6 +8,7 @@ namespace Robust.Server.GameObjects
{
public sealed class VisibilitySystem : EntitySystem
{
[Dependency] private readonly PvsSystem _pvs = default!;
[Dependency] private readonly IViewVariablesManager _vvManager = default!;
private EntityQuery<TransformComponent> _xformQuery;
@@ -133,6 +135,7 @@ namespace Robust.Server.GameObjects
var xform = _xformQuery.GetComponent(uid);
meta.VisibilityMask = mask;
_pvs.SyncMetadata(meta);
foreach (var child in xform._children)
{

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using Prometheus;
using Robust.Server.GameStates;
using Robust.Server.Player;
using Robust.Shared;
using Robust.Shared.Configuration;
@@ -42,7 +43,7 @@ namespace Robust.Server.GameObjects
#endif
private ISawmill _netEntSawmill = default!;
private EntityQuery<ActorComponent> _actorQuery;
private PvsSystem _pvs = default!;
public override void Initialize()
{
@@ -57,7 +58,7 @@ namespace Robust.Server.GameObjects
public override void Startup()
{
base.Startup();
_actorQuery = GetEntityQuery<ActorComponent>();
_pvs = System<PvsSystem>();
}
EntityUid IServerEntityManagerInternal.AllocEntity(EntityPrototype? prototype)
@@ -103,6 +104,40 @@ namespace Robust.Server.GameObjects
return entity;
}
/// <inheritdoc />
public override void RaiseSharedEvent<T>(T message, EntityUid? user = null)
{
if (user != null)
{
var filter = Filter.Broadcast().RemoveWhereAttachedEntity(e => e == user.Value);
foreach (var session in filter.Recipients)
{
EntityNetManager.SendSystemNetworkMessage(message, session.Channel);
}
}
else
{
EntityNetManager.SendSystemNetworkMessage(message);
}
}
/// <inheritdoc />
public override void RaiseSharedEvent<T>(T message, ICommonSession? user = null)
{
if (user != null)
{
var filter = Filter.Broadcast().RemovePlayer(user);
foreach (var session in filter.Recipients)
{
EntityNetManager.SendSystemNetworkMessage(message, session.Channel);
}
}
else
{
EntityNetManager.SendSystemNetworkMessage(message);
}
}
private void ClearTicks(EntityUid entity, EntityPrototype prototype)
{
foreach (var (netId, component) in GetNetComponents(entity))
@@ -116,6 +151,12 @@ namespace Robust.Server.GameObjects
}
}
internal override void SetLifeStage(MetaDataComponent meta, EntityLifeStage stage)
{
base.SetLifeStage(meta, stage);
_pvs.SyncMetadata(meta);
}
#region IEntityNetworkManager impl
public override IEntityNetworkManager EntityNetManager => this;

View File

@@ -42,7 +42,7 @@ internal sealed class PvsChunk
/// <remarks>
/// This already includes <see cref="Map"/>, <see cref="Root"/>, and <see cref="Children"/>
/// </remarks>
public readonly List<Entity<MetaDataComponent>> Contents = new();
public readonly List<ChunkEntity> Contents = new();
/// <summary>
/// The unique location identifier for this chunk.
@@ -68,8 +68,8 @@ internal sealed class PvsChunk
// the same chunk can be repopulated more than once.
private List<HashSet<EntityUid>> _childSets = new();
private List<HashSet<EntityUid>> _nextChildSets = new();
private List<Entity<MetaDataComponent>> _lowPriorityChildren = new();
private List<Entity<MetaDataComponent>> _anchoredChildren = new();
private List<ChunkEntity> _lowPriorityChildren = new();
private List<ChunkEntity> _anchoredChildren = new();
/// <summary>
/// Effective "counts" of <see cref="Contents"/> that should be used to limit the number of entities in a chunk that
@@ -151,11 +151,11 @@ internal sealed class PvsChunk
childMeta.LastPvsLocation = Location;
if ((childMeta.Flags & MetaDataFlags.PvsPriority) == MetaDataFlags.PvsPriority)
Contents.Add((child, childMeta));
Contents.Add(new ChunkEntity(child, childMeta));
else if (childXform.Anchored)
_anchoredChildren.Add((child, childMeta));
_anchoredChildren.Add(new(child, childMeta));
else
_lowPriorityChildren.Add((child, childMeta));
_lowPriorityChildren.Add(new(child, childMeta));
var subCount = childXform._children.Count;
if (subCount == 0)
@@ -196,7 +196,7 @@ internal sealed class PvsChunk
}
childMeta.LastPvsLocation = Location;
Contents.Add((child, childMeta));
Contents.Add(new(child, childMeta));
var subCount = childXform._children.Count;
if (subCount == 0)
@@ -241,10 +241,10 @@ internal sealed class PvsChunk
set.Add(Map.Owner);
foreach (var child in Contents)
{
var parent = query.GetComponent(child).ParentUid;
var parent = query.GetComponent(child.Uid).ParentUid;
DebugTools.Assert(set.Contains(parent),
"A child's parent is not in the chunk, or is not listed first.");
DebugTools.Assert(set.Add(child), "Child appears more than once in the chunk.");
DebugTools.Assert(set.Add(child.Uid), "Child appears more than once in the chunk.");
}
}
@@ -274,4 +274,11 @@ internal sealed class PvsChunk
? $"map-{Root.Owner}-{Location.Indices}"
: $"grid-{Root.Owner}-{Location.Indices}";
}
public readonly struct ChunkEntity(EntityUid uid, MetaDataComponent meta)
{
public readonly EntityUid Uid = uid;
public readonly PvsIndex Ptr = meta.PvsData;
public readonly MetaDataComponent Meta = meta;
}
}

View File

@@ -1,9 +1,10 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Robust.Shared.Collections;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.Maths;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Timing;
@@ -14,26 +15,24 @@ namespace Robust.Server.GameStates;
/// <summary>
/// Class for storing session specific PVS data.
/// </summary>
internal sealed class PvsSession(ICommonSession session)
internal sealed class PvsSession(ICommonSession session, ResizableMemoryRegion<PvsData> memoryRegion)
{
public readonly ICommonSession Session = session;
public readonly ResizableMemoryRegion<PvsData> DataMemory = memoryRegion;
public INetChannel Channel => Session.Channel;
/// <summary>
/// All <see cref="EntityUid"/>s that this session saw during the last <see cref="PvsSystem.DirtyBufferSize"/> ticks.
/// All entities that this session saw during the last <see cref="PvsSystem.DirtyBufferSize"/> ticks.
/// </summary>
public readonly OverflowDictionary<GameTick, List<PvsData>> PreviouslySent = new(PvsSystem.DirtyBufferSize);
/// <summary>
/// Dictionary containing data about all entities that this client has ever seen.
/// </summary>
public readonly Dictionary<NetEntity, PvsData> Entities = new();
public readonly OverflowDictionary<GameTick, List<PvsIndex>> PreviouslySent = new(PvsSystem.DirtyBufferSize);
/// <summary>
/// <see cref="PreviouslySent"/> overflow in case a player's last ack is more than
/// <see cref="PvsSystem.DirtyBufferSize"/> ticks behind the current tick.
/// </summary>
public (GameTick Tick, List<PvsData> SentEnts)? Overflow;
public (GameTick Tick, List<PvsIndex> SentEnts)? Overflow;
/// <summary>
/// The client's current visibility mask.
@@ -43,13 +42,13 @@ internal sealed class PvsSession(ICommonSession session)
/// <summary>
/// The list that is currently being prepared for sending.
/// </summary>
public List<PvsData>? ToSend;
public List<PvsIndex>? ToSend;
/// <summary>
/// The <see cref="ToSend"/> list from the previous tick. Also caches the current tick that the PVS leave message
/// should belong to, in case the processing is ever run asynchronously with normal system/game ticking.
/// </summary>
public (GameTick ToTick, List<PvsData> PreviouslySent)? LastSent;
public (GameTick ToTick, List<PvsIndex> PreviouslySent)? LastSent;
/// <summary>
/// Visible chunks, sorted by proximity to the clients's viewers;
@@ -126,10 +125,12 @@ internal sealed class PvsSession(ICommonSession session)
/// <summary>
/// Class for storing session-specific information about when an entity was last sent to a player.
/// </summary>
internal sealed class PvsData(NetEntity entity) : IEquatable<PvsData>
/// <remarks>
/// Size is padded to 16 bytes so
/// </remarks>
[StructLayout(LayoutKind.Sequential, Size = 16)]
internal struct PvsData
{
public readonly NetEntity NetEntity = entity;
/// <summary>
/// Tick at which this entity was last sent to a player.
/// </summary>
@@ -146,22 +147,55 @@ internal sealed class PvsData(NetEntity entity) : IEquatable<PvsData>
/// present in that state.
/// </summary>
public GameTick EntityLastAcked;
}
public bool Equals(PvsData? other)
{
DebugTools.Assert((NetEntity != other?.NetEntity) || ReferenceEquals(this, other));
return NetEntity == other?.NetEntity;
}
/// <summary>
/// Specialized struct with the same size as <see cref="PvsData"/> that is used to store metadata in the pinned PVsData array
/// </summary>
[StructLayout(LayoutKind.Sequential, Size = 16)]
internal struct PvsMetadata
{
/// <summary>
/// Tick at which this entity was last sent to a player.
/// </summary>
public NetEntity NetEntity;
public override int GetHashCode()
public GameTick LastModifiedTick;
public ushort VisMask;
public EntityLifeStage LifeStage;
#if DEBUG
// This struct is padded to a size of 16 so it's aligned to cache boundaries nicely.
// We have this extra space that isn't being used,
// so I'm opting to use them to make debugging the free list easier.
// "Marker" overlaps with the field used by the free list (which occupies the unused memory of PvsMetadata).
// So we set it to a bogus value and BAM! Errors are obvious!
private byte Pad0;
public uint Marker;
#endif
}
[StructLayout(LayoutKind.Sequential, Size = 16)]
internal struct PvsMetadataFreeLink
{
#if DEBUG
static unsafe PvsMetadataFreeLink()
{
return NetEntity.GetHashCode();
DebugTools.Assert(sizeof(PvsMetadataFreeLink) == sizeof(PvsMetadata));
}
#endif
public int Pad0;
public int Pad1;
public int Pad2;
// We offset the NextFree to be at the end of the struct.
// This is so that it overlaps with the debug Marker field of PvsMetadata instead of real data.
public PvsIndex NextFree;
}
/// <summary>
/// Struct for storing information about the current number of entities that are being sent to the player this tick.
/// Used to enforce pvs budgets.
/// </summary>
internal struct PvsBudget
{
public int NewLimit;

View File

@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Robust.Server.Player;
using Robust.Shared.Console;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
@@ -12,6 +14,7 @@ namespace Robust.Server.GameStates;
public sealed class PvsOverrideSystem : EntitySystem
{
[Dependency] private readonly IPlayerManager _player = default!;
[Dependency] private readonly IConsoleHost _console = default!;
private readonly HashSet<EntityUid> _hasOverride = new();
@@ -28,8 +31,64 @@ public sealed class PvsOverrideSystem : EntitySystem
SubscribeLocalEvent<MapChangedEvent>(OnMapChanged);
SubscribeLocalEvent<GridInitializeEvent>(OnGridCreated);
SubscribeLocalEvent<GridRemovalEvent>(OnGridRemoved);
// TODO console commands for adding/removing overrides?
_console.RegisterCommand(
"pvs_override_info",
Loc.GetString("cmd-pvs-override-info-desc"),
"pvs_override_info",
GetPvsInfo,
GetCompletion);
}
#region Console Commands
/// <summary>
/// Debug command for displaying PVS override information.
/// </summary>
private void GetPvsInfo(IConsoleShell shell, string argstr, string[] args)
{
if (args.Length != 1)
{
shell.WriteError(Loc.GetString("cmd-invalid-arg-number-error"));
return;
}
if (!NetEntity.TryParse(args[0], out var nuid) || !TryGetEntity(nuid, out var uid))
{
shell.WriteError(Loc.GetString("cmd-parse-failure-uid"));
return;
}
if (!_hasOverride.Contains(uid.Value))
{
shell.WriteLine(Loc.GetString("cmd-pvs-override-info-empty", ("nuid", args[0])));
return;
}
if (GlobalOverride.Contains(uid.Value) || ForceSend.Contains(uid.Value))
shell.WriteLine(Loc.GetString("cmd-pvs-override-info-global", ("nuid", args[0])));
HashSet<ICommonSession> sessions = new();
sessions.UnionWith(SessionOverrides.Where(x => x.Value.Contains(uid.Value)).Select(x => x.Key));
sessions.UnionWith(SessionForceSend.Where(x => x.Value.Contains(uid.Value)).Select(x => x.Key));
if (sessions.Count == 0)
return;
var clients = string.Join(", ", sessions.Select(x => x.ToString()));
shell.WriteLine(Loc.GetString("cmd-pvs-override-info-clients", ("nuid", args[0]), ("clients", clients)));
}
private CompletionResult GetCompletion(IConsoleShell shell, string[] args)
{
if (args.Length != 1)
return CompletionResult.Empty;
return CompletionResult.FromHintOptions(CompletionHelper.NetEntities(args[0], EntityManager), "NetEntity");
}
#endregion
private void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs ev)
{
if (ev.NewStatus != SessionStatus.Disconnected)

View File

@@ -4,6 +4,7 @@ using System.Runtime.InteropServices;
using System.Threading;
using Prometheus;
using Robust.Shared.Enums;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Player;
using Robust.Shared.Threading;
@@ -105,7 +106,7 @@ internal sealed partial class PvsSystem
private void ProcessQueuedAck(PvsSession session)
{
var ackedTick = session.LastReceivedAck;
List<PvsData>? ackedEnts;
List<PvsIndex>? ackedEnts;
if (session.Overflow != null && session.Overflow.Value.Tick <= ackedTick)
{
@@ -125,12 +126,12 @@ internal sealed partial class PvsSystem
else if (!session.PreviouslySent.TryGetValue(ackedTick, out ackedEnts))
return;
foreach (var data in CollectionsMarshal.AsSpan(ackedEnts))
foreach (ref var intPtr in CollectionsMarshal.AsSpan(ackedEnts))
{
data.EntityLastAcked = ackedTick;
ref var data = ref session.DataMemory.GetRef(intPtr.Index);
DebugTools.AssertNotEqual(data.LastSeen, GameTick.Zero);
DebugTools.Assert(data.LastSeen >= ackedTick); // LastSent may equal ackedTick if the packet was sent reliably.
DebugTools.Assert(!session.Entities.TryGetValue(data.NetEntity, out var old)
|| ReferenceEquals(data, old));
data.EntityLastAcked = ackedTick;
}
// The client acked a tick. If they requested a full state, this ack happened some time after that, so we can safely set this to false

View File

@@ -0,0 +1,384 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using Robust.Shared;
using Robust.Shared.GameObjects;
using Robust.Shared.Threading;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.Server.GameStates;
// This partial class handles the PvsData memory. This array stores information about when each entity was last sent to
// each player. This is somewhat faster than using a per-player Dictionary<EntityUid, PvsData>, though it can be less
// memory efficient.
internal sealed partial class PvsSystem
{
// This is used for asserts.
private HashSet<PvsIndex> _assignedEnts = new();
/// <summary>
/// Recently returned indexes from deleted entities. These get moved to <see cref="_pendingReturns"/> before
/// moving back into the free list.
/// </summary>
private List<PvsIndex> _incomingReturns = new();
/// <summary>
/// Recently returned pointers from deleted entities. These will get returned to the free list
/// after a minimum amount of time has passed, to ensure that processing late game-state ack messages doesn't
/// write data to deleted entities.
/// </summary>
private List<PvsIndex> _pendingReturns = new();
/// <summary>
/// Tick at which the <see cref="_pendingReturns"/> were last processed.
/// </summary>
private GameTick _lastReturn = GameTick.Zero;
/// <summary>
/// Memory region to store <see cref="PvsMetadata"/> instances and the free list.
/// </summary>
/// <remarks>
/// Unused elements form a linked list out of <see cref="PvsMetadataFreeLink"/> elements.
/// </remarks>
private ResizableMemoryRegion<PvsMetadata> _metadataMemory = default!;
/// <summary>
/// The head of the PVS data free list. This is the first element that will be used if a new one is needed.
/// </summary>
/// <remarks>
/// If the value is <see cref="PvsIndex.Invalid"/>,
/// there are no more free elements and the next allocation must expand the memory.
/// </remarks>
private PvsIndex _dataFreeListHead;
private WaitHandle? _deletionTask;
/// <summary>
/// Expand the size of <see cref="_metadataMemory"/> (and all session data stores) one iteration.
/// </summary>
/// <remarks>
/// This ensures that we have at least one free list slot.
/// </remarks>
private void ExpandEntityCapacity()
{
var initial = _metadataMemory.CurrentSize;
var entityGrowth = _configManager.GetCVar(CVars.NetPvsEntityGrowth);
var newSize = initial + (entityGrowth <= 0 ? initial : entityGrowth);
newSize = Math.Min(newSize, _metadataMemory.MaxSize);
if (newSize == initial)
throw new InvalidOperationException("Out of PVS entity capacity! Increase net.pvs_entity_max!");
Log.Debug($"Growing PvsData memory from {initial} -> {newSize} entities");
_metadataMemory.Expand(newSize);
foreach (var playerSession in PlayerData.Values)
{
playerSession.DataMemory.Expand(newSize);
}
var newSlots = _metadataMemory.GetSpan<PvsMetadataFreeLink>()[initial..];
InitializeFreeList(newSlots, initial, ref _dataFreeListHead);
}
/// <summary>
/// Initialize <see cref="_metadataMemory"/> and the free list.
/// </summary>
private void InitializePvsArray()
{
var initialCount = _configManager.GetCVar(CVars.NetPvsEntityInitial);
var maxCount = _configManager.GetCVar(CVars.NetPvsEntityMax);
if (initialCount <= 0 || maxCount <= 0)
throw new InvalidOperationException("net.pvs_entity_initial and net.pvs_entity_max must be positive");
_metadataMemory = new ResizableMemoryRegion<PvsMetadata>(maxCount, initialCount);
ResetDataMemory();
}
/// <summary>
/// Initialize a section of the free list.
/// </summary>
/// <param name="memory">The section of the free list to initialize.</param>
/// <param name="baseOffset">What offset in the total PVS data this section starts at.</param>
/// <param name="head">The current head storage of the free list to update.</param>
private static void InitializeFreeList(Span<PvsMetadataFreeLink> memory, int baseOffset, ref PvsIndex head)
{
for (var i = 0; i < memory.Length; i++)
{
memory[i].NextFree = new PvsIndex(baseOffset + i + 1);
}
memory[^1].NextFree = head;
head = new PvsIndex(baseOffset);
}
/// <summary>
/// Clear all PVS data. After this function is called,
/// <see cref="ResetDataMemory"/> must be called if the system isn't being shut down.
/// </summary>
private void ClearPvsData()
{
_leaveTask?.WaitOne();
_leaveTask = null;
_deletionTask?.WaitOne();
_deletionTask = null;
_incomingReturns.Clear();
_pendingReturns.Clear();
_deletionJob.ToClear.Clear();
_assignedEnts.Clear();
// Remove all pointers stored in any player's PVS send-histories. Required to avoid accidentally writing to
// invalid bits of memory while processing late game-state acks. This also forces all players to receive a full
// game state, in lieu of sending the required PVS leave messages.
foreach (var session in PlayerData.Values)
{
session.DataMemory.Clear();
ForceFullState(session);
}
_metadataMemory.Clear();
}
/// <summary>
/// Re-initialize the memory in <see cref="_metadataMemory"/> after it was fully cleared on reset.
/// </summary>
private void ResetDataMemory()
{
_dataFreeListHead = PvsIndex.Invalid;
InitializeFreeList(_metadataMemory.GetSpan<PvsMetadataFreeLink>(), 0, ref _dataFreeListHead);
}
/// <summary>
/// Shrink <see cref="_metadataMemory"/> (and all sessions) back down to initial entity size after clear.
/// </summary>
private void ShrinkDataMemory()
{
DebugTools.Assert(EntityManager.EntityCount == 0);
var initialCount = _configManager.GetCVar(CVars.NetPvsEntityInitial);
if (initialCount != _metadataMemory.CurrentSize)
{
Log.Debug($"Shrinking PVS data from {_metadataMemory.CurrentSize} -> {initialCount} entities");
_metadataMemory.Shrink(initialCount);
foreach (var player in PlayerData.Values)
{
player.DataMemory.Shrink(initialCount);
}
}
}
/// <summary>
/// This method shuffles the entity free list. This is used to avoid accidental / unrealistic cache locality
/// in benchmarks.
/// </summary>
internal void ShufflePointers(int seed)
{
throw new NotImplementedException();
/*List<IntPtr> ptrs = new(_pointerPool);
_pointerPool.Clear();
var rng = new Random(seed);
var n = ptrs.Count;
while (n > 0)
{
var k = rng.Next(n);
_pointerPool.Push(ptrs[k]);
ptrs[k] = ptrs[^1];
ptrs.RemoveAt(--n);
}*/
}
/// <summary>
/// Clear all of this sessions' PvsData for all entities. This effectively means that PVS will act as if the player
/// had never been sent information about any entity. Used when returning the player's index offset to the pool.
/// </summary>
private void ClearPlayerPvsData(PvsSession session)
{
session.DataMemory.Clear();
}
/// <summary>
/// Clear all of this entity' PvsData entries. This effectively means that PVS will act as if no player
/// had never been sent information about this entity. Used when returning the entity's index back to the free list.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ClearEntityPvsData(PvsIndex index)
{
foreach (var playerData in PlayerData.Values)
{
ref var entry = ref playerData.DataMemory.GetRef(index.Index);
entry = default;
}
}
/// <summary>
/// Get the NetEntity associated with a given <see cref="PvsIndex"/>.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private NetEntity IndexToNetEntity(PvsIndex index)
{
DebugTools.Assert(_assignedEnts.Contains(index));
return _metadataMemory.GetRef(index.Index).NetEntity;
}
/// <summary>
/// Create a new <see cref="ResizableMemoryRegion{T}"/> suitable for assigning to a new <see cref="PvsSession"/>.
/// </summary>
private ResizableMemoryRegion<PvsData> CreateSessionDataMemory()
{
return new ResizableMemoryRegion<PvsData>(_metadataMemory.MaxSize, _metadataMemory.CurrentSize);
}
private static void FreeSessionDataMemory(PvsSession session)
{
session.DataMemory.Dispose();
}
private void OnEntityAdded(Entity<MetaDataComponent> entity)
{
DebugTools.Assert(entity.Comp.PvsData.Index == default);
AssignEntityPointer(entity.Comp);
}
/// <summary>
/// Retrieve a free entity index and assign it to an entity.
/// </summary>
private void AssignEntityPointer(MetaDataComponent meta)
{
if (_dataFreeListHead == PvsIndex.Invalid)
{
ExpandEntityCapacity();
DebugTools.Assert(_dataFreeListHead != PvsIndex.Invalid);
}
var index = _dataFreeListHead;
DebugTools.Assert(_assignedEnts.Add(index));
ref var metadata = ref _metadataMemory.GetRef(index.Index);
ref var freeLink = ref Unsafe.As<PvsMetadata, PvsMetadataFreeLink>(ref metadata);
_dataFreeListHead = freeLink.NextFree;
// TODO: re-introduce this assert.
// DebugTools.AssertEqual(((PvsMetadata*) ptr)->NetEntity, NetEntity.Invalid);
DebugTools.AssertNotEqual(meta.NetEntity, NetEntity.Invalid);
meta.PvsData = index;
metadata.NetEntity = meta.NetEntity;
metadata.LastModifiedTick = meta.LastModifiedTick;
metadata.VisMask = meta.VisibilityMask;
metadata.LifeStage = meta.EntityLifeStage;
#if DEBUG
metadata.Marker = uint.MaxValue;
#endif
}
/// <summary>
/// Return an entity's index in the data array back to the free list of available indices.
/// </summary>
private void OnEntityDeleted(Entity<MetaDataComponent> entity)
{
var ptr = entity.Comp.PvsData;
entity.Comp.PvsData = default;
if (ptr == default)
return;
_incomingReturns.Add(ptr);
}
/// <summary>
/// Immediately return all data indexes back to the pool after flushing all entities.
/// </summary>
private void AfterEntityFlush()
{
DebugTools.Assert(EntityManager.EntityCount == 0);
ClearPvsData();
ShrinkDataMemory();
ResetDataMemory();
}
/// <summary>
/// This update method periodically returns entity indices back to the pool, once we are sure no old
/// game state acks will use indices to that entity.
/// </summary>
private void ProcessDeletions()
{
var curTick = _gameTiming.CurTick;
if (curTick < _lastReturn + (uint)ForceAckThreshold + 1)
return;
if (curTick < _lastReturn)
throw new InvalidOperationException($"Time travel is not supported");
_leaveTask?.WaitOne();
_leaveTask = null;
_deletionTask?.WaitOne();
_deletionTask = null;
_lastReturn = curTick;
foreach (var index in CollectionsMarshal.AsSpan(_deletionJob.ToClear))
{
ReturnEntity(index);
}
_deletionJob.ToClear.Clear();
// Cycle lists.
(_deletionJob.ToClear, _pendingReturns, _incomingReturns) = (_pendingReturns, _incomingReturns, _deletionJob.ToClear);
if (_deletionJob.ToClear.Count == 0)
return;
#if DEBUG
foreach (var index in CollectionsMarshal.AsSpan(_deletionJob.ToClear))
{
DebugTools.Assert(_assignedEnts.Remove(index));
}
#endif
if (_deletionJob.ToClear.Count > 16)
{
_deletionTask = _parallelManager.Process(_deletionJob, _deletionJob.Count);
return;
}
foreach (var index in CollectionsMarshal.AsSpan(_deletionJob.ToClear))
{
ClearEntityPvsData(index);
}
}
private void ReturnEntity(PvsIndex index)
{
DebugTools.Assert(!_assignedEnts.Contains(index));
ref var freeLink = ref _metadataMemory.GetRef<PvsMetadataFreeLink>(index.Index);
freeLink.NextFree = _dataFreeListHead;
_dataFreeListHead = index;
}
private record struct PvsDeletionsJob(PvsSystem _pvs) : IParallelRobustJob
{
public int BatchSize => 8;
private PvsSystem _pvs = _pvs;
public List<PvsIndex> ToClear = new();
public int Count => ToClear.Count;
public void Execute(int index)
{
_pvs.ClearEntityPvsData(ToClear[index]);
}
}
}

View File

@@ -49,6 +49,12 @@ namespace Robust.Server.GameStates
private void OnEntityDirty(Entity<MetaDataComponent> uid)
{
if (uid.Comp.PvsData != default)
{
ref var meta = ref _metadataMemory.GetRef(uid.Comp.PvsData.Index);
meta.LastModifiedTick = uid.Comp.EntityLastModifiedTick;
}
if (!_addEntities[_currentIndex].Contains(uid))
_dirtyEntities[_currentIndex].Add(uid);
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Diagnostics;
using Robust.Shared.GameObjects;
using Robust.Shared.Map.Components;
@@ -25,11 +26,6 @@ internal sealed partial class PvsSystem
{
var meta = ev.Entity.Comp;
foreach (var sessionData in PlayerData.Values)
{
sessionData.Entities.Remove(meta.NetEntity);
}
_deletedEntities.Add(meta.NetEntity);
_deletedTick.Add(_gameTiming.CurTick);
RemoveEntityFromChunk(ev.Entity.Owner, meta);
@@ -111,4 +107,14 @@ internal sealed partial class PvsSystem
DebugTools.AssertNull(xform.MapUid);
AssertNullspace(xform.ParentUid);
}
internal void SyncMetadata(MetaDataComponent meta)
{
if (meta.PvsData == default)
return;
ref var ptr = ref _metadataMemory.GetRef(meta.PvsData.Index);
ptr.VisMask = meta.VisibilityMask;
ptr.LifeStage = meta.EntityLifeStage;
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using Prometheus;
@@ -47,12 +48,14 @@ internal sealed partial class PvsSystem
return;
var (toTick, lastSent) = session.LastSent.Value;
foreach (var data in CollectionsMarshal.AsSpan(lastSent))
foreach (var intPtr in CollectionsMarshal.AsSpan(lastSent))
{
ref var data = ref session.DataMemory.GetRef(intPtr.Index);
if (data.LastSeen == toTick)
continue;
session.LeftView.Add(data.NetEntity);
session.LeftView.Add(IndexToNetEntity(intPtr));
data.LastLeftView = toTick;
}

View File

@@ -1,4 +1,6 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Robust.Shared.GameObjects;
using Robust.Shared.Timing;
@@ -8,21 +10,23 @@ namespace Robust.Server.GameStates;
// range/chunk restrictions.
internal sealed partial class PvsSystem
{
private readonly List<Entity<MetaDataComponent>> _cachedForceOverride = new();
private readonly List<Entity<MetaDataComponent>> _cachedGlobalOverride = new();
private readonly List<PvsChunk.ChunkEntity> _cachedForceOverride = new();
private readonly List<PvsChunk.ChunkEntity> _cachedGlobalOverride = new();
private readonly HashSet<EntityUid> _forceOverrideSet = new();
private readonly HashSet<EntityUid> _globalOverrideSet = new();
private void AddAllOverrides(PvsSession session)
{
var mask = session.VisMask;
var fromTick = session.FromTick;
RaiseExpandEvent(session, fromTick);
foreach (var entity in _cachedGlobalOverride)
foreach (ref var ent in CollectionsMarshal.AsSpan(_cachedGlobalOverride))
{
if (!AddEntity(session, entity, fromTick))
break;
ref var meta = ref _metadataMemory.GetRef(ent.Ptr.Index);
if ((mask & meta.VisMask) == meta.VisMask)
AddEntity(session, ref ent, ref meta, fromTick);
}
if (!_pvsOverride.SessionOverrides.TryGetValue(session.Session, out var sessionOverrides))
@@ -42,10 +46,13 @@ internal sealed partial class PvsSystem
// Ignore PVS budgets
session.Budget = new() {NewLimit = int.MaxValue, EnterLimit = int.MaxValue};
var mask = session.VisMask;
var fromTick = session.FromTick;
foreach (var entity in _cachedForceOverride)
foreach (ref var ent in CollectionsMarshal.AsSpan(_cachedForceOverride))
{
AddEntity(session, entity, fromTick);
ref var meta = ref _metadataMemory.GetRef(ent.Ptr.Index);
if ((mask & meta.VisMask) == meta.VisMask)
AddEntity(session, ref ent, ref meta, fromTick);
}
foreach (var uid in session.Viewers)
@@ -156,7 +163,7 @@ internal sealed partial class PvsSystem
private bool CacheOverrideParents(
EntityUid uid,
List<Entity<MetaDataComponent>> list,
List<PvsChunk.ChunkEntity> list,
HashSet<EntityUid> set,
out TransformComponent xform)
{
@@ -175,11 +182,11 @@ internal sealed partial class PvsSystem
return false;
}
list.Add((uid, meta));
list.Add(new(uid, meta));
return true;
}
private void CacheOverrideChildren(TransformComponent xform, List<Entity<MetaDataComponent>> list, HashSet<EntityUid> set)
private void CacheOverrideChildren(TransformComponent xform, List<PvsChunk.ChunkEntity> list, HashSet<EntityUid> set)
{
foreach (var child in xform._children)
{
@@ -190,7 +197,7 @@ internal sealed partial class PvsSystem
}
if (set.Add(child))
list.Add((child, _metaQuery.GetComponent(child)));
list.Add(new(child, _metaQuery.GetComponent(child)));
CacheOverrideChildren(childXform, list, set);
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.ObjectPool;
using Robust.Shared.GameObjects;
@@ -15,8 +16,8 @@ internal sealed partial class PvsSystem
/// </summary>
private const int MaxVisPoolSize = 1024;
private readonly ObjectPool<List<PvsData>> _entDataListPool
= new DefaultObjectPool<List<PvsData>>(new ListPolicy<PvsData>(), MaxVisPoolSize);
private readonly ObjectPool<List<PvsIndex>> _entDataListPool
= new DefaultObjectPool<List<PvsIndex>>(new ListPolicy<PvsIndex>(), MaxVisPoolSize);
private readonly ObjectPool<HashSet<EntityUid>> _uidSetPool
= new DefaultObjectPool<HashSet<EntityUid>>(new SetPolicy<EntityUid>(), MaxVisPoolSize);

View File

@@ -72,7 +72,10 @@ internal sealed partial class PvsSystem
private PvsSession GetOrNewPvsSession(ICommonSession session)
{
if (!PlayerData.TryGetValue(session, out var pvsSession))
PlayerData[session] = pvsSession = new(session);
{
var memoryRegion = CreateSessionDataMemory();
PlayerData[session] = pvsSession = new(session, memoryRegion);
}
return pvsSession;
}
@@ -193,6 +196,5 @@ internal sealed partial class PvsSystem
session.PreviouslySent.Clear();
session.LastSent = null;
session.Entities.Clear();
}
}

View File

@@ -61,71 +61,91 @@ internal sealed partial class PvsSystem
for (var i = 0; i < limit; i++)
{
var ent = span[i];
if ((mask & ent.Comp.VisibilityMask) != ent.Comp.VisibilityMask)
ref var meta = ref _metadataMemory.GetRef(ent.Ptr.Index);
if ((mask & meta.VisMask) != meta.VisMask)
continue;
// TODO PVS improve this somehow
// Having entities "leave" pvs view just because the pvs entry budget was exceeded sucks.
// This probably requires changing client game state manager to support receiving entities with unknown parents.
// Probably needs to do something similar to pending net entity states, but for entity spawning.
if (!AddEntity(session, ent, fromTick))
if (!AddEntity(session, ref ent, ref meta, fromTick))
limit = directChildren;
}
}
/// <summary>
/// Attempt to add an entity to the to-send lists, while respecting pvs budgets.
/// </summary>
/// <returns>Returns false if the entity would exceed the client's PVS budget.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool AddEntity(PvsSession session, ref PvsChunk.ChunkEntity ent, ref PvsMetadata meta,
GameTick fromTick)
{
DebugTools.Assert(fromTick < _gameTiming.CurTick);
ref var data = ref session.DataMemory.GetRef(ent.Ptr.Index);
if (data.LastSeen == _gameTiming.CurTick)
return true;
if (meta.LifeStage >= EntityLifeStage.Terminating)
{
Log.Error($"Attempted to send deleted entity: {ToPrettyString(ent.Uid)}");
EntityManager.QueueDeleteEntity(ent.Uid);
return true;
}
var (entered,budgetExceeded) = IsEnteringPvsRange(ref data, fromTick, ref session.Budget);
if (budgetExceeded)
return false;
data.LastSeen = _gameTiming.CurTick;
session.ToSend!.Add(ent.Ptr);
if (session.RequestedFull)
{
var state = GetFullEntityState(session.Session, ent.Uid, ent.Meta);
session.States.Add(state);
return true;
}
if (entered)
{
var state = GetEntityState(session.Session, ent.Uid, data.EntityLastAcked, ent.Meta);
session.States.Add(state);
return true;
}
if (meta.LastModifiedTick <= fromTick)
return true;
var entState = GetEntityState(session.Session, ent.Uid, fromTick , ent.Meta);
if (!entState.Empty)
session.States.Add(entState);
return true;
}
/// <summary>
/// Attempt to add an entity to the to-send lists, while respecting pvs budgets.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool AddEntity(PvsSession session, Entity<MetaDataComponent> entity, GameTick fromTick)
{
var nuid = entity.Comp.NetEntity;
ref var data = ref CollectionsMarshal.GetValueRefOrAddDefault(session.Entities, nuid, out var exists);
if (!exists)
data = new(nuid);
DebugTools.Assert(fromTick < _gameTiming.CurTick);
ref var data = ref session.DataMemory.GetRef(entity.Comp.PvsData.Index);
if (entity.Comp.Deleted)
{
Log.Error($"Attempted to send deleted entity: {ToPrettyString(entity, entity)}");
session.Entities.Remove(entity.Comp.NetEntity);
return false;
}
DebugTools.AssertEqual(data!.NetEntity, entity.Comp.NetEntity);
if (data.LastSeen == _gameTiming.CurTick)
return true;
var (entered,budgetExceeded) = IsEnteringPvsRange(data, fromTick, ref session.Budget);
var (entered,budgetExceeded) = IsEnteringPvsRange(ref data, fromTick, ref session.Budget);
if (budgetExceeded)
return false;
if (!AddToSendList(session, data, entity, fromTick, entered))
return false;
DebugTools.AssertNotEqual(data.LastSeen, GameTick.Zero);
return true;
}
/// <summary>
/// This method adds an entity to the list of visible entities, updates the last-seen tick, and computes any
/// required game states.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool AddToSendList(PvsSession session, PvsData data, Entity<MetaDataComponent> entity, GameTick fromTick,
bool entered)
{
DebugTools.Assert(fromTick < _gameTiming.CurTick);
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
if (data == null)
{
Log.Error($"Encountered null EntityData.");
return false;
}
DebugTools.AssertNotEqual(data.LastSeen, _gameTiming.CurTick);
DebugTools.Assert(data.EntityLastAcked <= fromTick || fromTick == GameTick.Zero);
var (uid, meta) = entity;
// ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
@@ -147,19 +167,18 @@ internal sealed partial class PvsSystem
}
data.LastSeen = _gameTiming.CurTick;
session.ToSend!.Add(data);
EntityState state;
session.ToSend!.Add(entity.Comp.PvsData);
if (session.RequestedFull)
{
state = GetFullEntityState(session.Session, uid, meta);
var state = GetFullEntityState(session.Session, uid, meta);
session.States.Add(state);
return true;
}
if (entered)
{
state = GetEntityState(session.Session, uid, data.EntityLastAcked, meta);
var state = GetEntityState(session.Session, uid, data.EntityLastAcked, meta);
session.States.Add(state);
return true;
}
@@ -167,10 +186,10 @@ internal sealed partial class PvsSystem
if (meta.EntityLastModifiedTick <= fromTick)
return true;
state = GetEntityState(session.Session, uid, fromTick , meta);
var entState = GetEntityState(session.Session, uid, fromTick , meta);
if (!state.Empty)
session.States.Add(state);
if (!entState.Empty)
session.States.Add(entState);
return true;
}
@@ -181,7 +200,7 @@ internal sealed partial class PvsSystem
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private (bool Entering, bool BudgetExceeded) IsEnteringPvsRange(
PvsData data,
ref PvsData data,
GameTick fromTick,
ref PvsBudget budget)
{

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
@@ -15,6 +16,8 @@ using Robust.Server.Replays;
using Robust.Shared;
using Robust.Shared.Configuration;
using Robust.Shared.GameObjects;
using Robust.Shared.GameStates;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
using Robust.Shared.Network;
@@ -75,6 +78,7 @@ internal sealed partial class PvsSystem : EntitySystem
private PvsAckJob _ackJob;
private PvsChunkJob _chunkJob;
private PvsLeaveJob _leaveJob;
private PvsDeletionsJob _deletionJob;
private EntityQuery<EyeComponent> _eyeQuery;
private EntityQuery<MetaDataComponent> _metaQuery;
@@ -110,6 +114,10 @@ internal sealed partial class PvsSystem : EntitySystem
{
base.Initialize();
if (Marshal.SizeOf<PvsMetadata>() != Marshal.SizeOf<PvsData>())
throw new Exception($"Pvs struct sizes must match");
_deletionJob = new PvsDeletionsJob(this);
_leaveJob = new PvsLeaveJob(this);
_chunkJob = new PvsChunkJob(this);
_ackJob = new PvsAckJob(this);
@@ -125,6 +133,9 @@ internal sealed partial class PvsSystem : EntitySystem
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
_transform.OnGlobalMoveEvent += OnEntityMove;
EntityManager.EntityAdded += OnEntityAdded;
EntityManager.EntityDeleted += OnEntityDeleted;
EntityManager.AfterEntityFlush += AfterEntityFlush;
Subs.CVar(_configManager, CVars.NetPVS, SetPvs, true);
Subs.CVar(_configManager, CVars.NetMaxUpdateRange, OnViewsizeChanged, true);
@@ -135,10 +146,10 @@ internal sealed partial class PvsSystem : EntitySystem
_serverGameStateManager.ClientAck += OnClientAck;
_serverGameStateManager.ClientRequestFull += OnClientRequestFull;
_parallelMgr.ParallelCountChanged += ResetParallelism;
InitializeDirty();
_parallelMgr.ParallelCountChanged += ResetParallelism;
InitializePvsArray();
}
public override void Shutdown()
@@ -147,15 +158,22 @@ internal sealed partial class PvsSystem : EntitySystem
_playerManager.PlayerStatusChanged -= OnPlayerStatusChanged;
_transform.OnGlobalMoveEvent -= OnEntityMove;
EntityManager.EntityAdded -= OnEntityAdded;
EntityManager.EntityDeleted -= OnEntityDeleted;
EntityManager.AfterEntityFlush -= AfterEntityFlush;
_parallelMgr.ParallelCountChanged -= ResetParallelism;
_serverGameStateManager.ClientAck -= OnClientAck;
_serverGameStateManager.ClientRequestFull -= OnClientRequestFull;
ClearPvsData();
ShutdownDirty();
_leaveTask?.WaitOne();
_leaveTask = null;
}
public override void Update(float frameTime)
{
ProcessDeletions();
}
/// <summary>
@@ -253,6 +271,7 @@ internal sealed partial class PvsSystem : EntitySystem
session.LastReceivedAck = _gameTiming.CurTick;
session.RequestedFull = true;
ClearSendHistory(session);
ClearPlayerPvsData(session);
}
private void OnViewsizeChanged(float value)
@@ -342,19 +361,20 @@ internal sealed partial class PvsSystem : EntitySystem
{
var toSend = pvsSession.ToSend;
var toSendSet = new HashSet<NetEntity>(toSend!.Count);
foreach (var data in toSend)
foreach (var intPtr in toSend)
{
toSendSet.Add(data.NetEntity);
toSendSet.Add(IndexToNetEntity(intPtr));
}
DebugTools.AssertEqual(toSend.Count, toSendSet.Count);
foreach (var data in CollectionsMarshal.AsSpan(toSend))
foreach (var intPtr in CollectionsMarshal.AsSpan(toSend))
{
ref var data = ref pvsSession.DataMemory.GetRef(intPtr.Index);
DebugTools.AssertEqual(data.LastSeen, _gameTiming.CurTick);
DebugTools.Assert(ReferenceEquals(data, pvsSession.Entities[data.NetEntity]));
// if an entity is visible, its parents should always be visible.
if (_xformQuery.GetComponent(GetEntity(data.NetEntity)).ParentUid is not {Valid: true} pUid)
if (_xformQuery.GetComponent(GetEntity(IndexToNetEntity(intPtr))).ParentUid is not {Valid: true} pUid)
continue;
DebugTools.Assert(toSendSet.Contains(GetNetEntity(pUid)),
@@ -362,11 +382,11 @@ internal sealed partial class PvsSystem : EntitySystem
}
pvsSession.PreviouslySent.TryGetValue(_gameTiming.CurTick - 1, out var lastSent);
foreach (var data in CollectionsMarshal.AsSpan(lastSent))
foreach (var intPtr in CollectionsMarshal.AsSpan(lastSent))
{
DebugTools.Assert(!pvsSession.Entities.TryGetValue(data.NetEntity, out var old) || ReferenceEquals(data, old));
ref var data = ref pvsSession.DataMemory.GetRef(intPtr.Index);
DebugTools.Assert(data.LastSeen != GameTick.Zero);
DebugTools.AssertEqual(toSendSet.Contains(data.NetEntity), data.LastSeen == _gameTiming.CurTick);
DebugTools.AssertEqual(toSendSet.Contains(IndexToNetEntity(intPtr)), data.LastSeen == _gameTiming.CurTick);
DebugTools.Assert(data.LastSeen == _gameTiming.CurTick
|| data.LastSeen == _gameTiming.CurTick - 1);
}
@@ -409,7 +429,10 @@ internal sealed partial class PvsSystem : EntitySystem
foreach (var session in _disconnected)
{
if (PlayerData.Remove(session, out var pvsSession))
{
ClearSendHistory(pvsSession);
FreeSessionDataMemory(pvsSession);
}
}
var ackJob = ProcessQueuedAcks();

View File

@@ -5,6 +5,7 @@ using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Replays;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Robust.Server.Replays;
@@ -13,7 +14,7 @@ internal sealed class ReplayRecordingManager : SharedReplayRecordingManager, ISe
[Dependency] private readonly IEntitySystemManager _sysMan = default!;
private PvsSystem _pvs = default!;
private PvsSession _pvsSession = new(default!) { DisableCulling = true };
private PvsSession _pvsSession = new(default!, new ResizableMemoryRegion<PvsData>(1)) { DisableCulling = true };
public override void Initialize()
{

View File

@@ -697,6 +697,32 @@ namespace Robust.Shared.Maths
#endregion
/// <summary>
/// Round up (ceiling) a value to a multiple of a known power of two.
/// </summary>
/// <param name="value">The value to round up.</param>
/// <param name="powerOfTwo">
/// The power of two to round up to a multiple of. The result is undefined if this is not a power of two.
/// </param>
/// <remarks>
/// The result is undefined if either value is negative.
/// </remarks>
/// <typeparam name="T">The type of integer to operate on.</typeparam>
/// <example>
/// <code>
/// MathHelper.CeilMultiplyPowerOfTwo(5, 4) // 8
/// MathHelper.CeilMultiplyPowerOfTwo(4, 4) // 4
/// MathHelper.CeilMultiplyPowerOfTwo(8, 4) // 8
/// </code>
/// </example>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static T CeilMultipleOfPowerOfTwo<T>(T value, T powerOfTwo) where T : IBinaryInteger<T>
{
var mask = powerOfTwo - T.One;
var remainder = value & mask;
return remainder == T.Zero ? value : (value | mask) + T.One;
}
#endregion Public Members
}
}

View File

@@ -266,7 +266,7 @@ public abstract partial class SharedAudioSystem : EntitySystem
return sound == null ? null : PlayGlobal(GetSound(sound), recipient, sound.Params);
}
public abstract void LoadStream<T>(AudioComponent component, T stream);
public abstract void LoadStream<T>(Entity<AudioComponent> entity, T stream);
/// <summary>
/// Play an audio file globally, without position.

View File

@@ -183,6 +183,30 @@ namespace Robust.Shared
public static readonly CVarDef<bool> NetPVS =
CVarDef.Create("net.pvs", true, CVar.ARCHIVE | CVar.REPLICATED | CVar.SERVER);
/// <summary>
/// Size increments for the automatic growth of Pvs' entity data storage. 0 will increase it by factors of 2
/// </summary>
public static readonly CVarDef<int> NetPvsEntityGrowth =
CVarDef.Create("net.pvs_entity_growth", 1 << 16, CVar.ARCHIVE | CVar.SERVERONLY);
/// <summary>
/// Initial size of PVS' entity data storage.
/// </summary>
public static readonly CVarDef<int> NetPvsEntityInitial =
CVarDef.Create("net.pvs_entity_initial", 1 << 16, CVar.ARCHIVE | CVar.SERVERONLY);
/// <summary>
/// Maximum ever size of PVS' entity data storage.
/// </summary>
/// <remarks>
/// <para>
/// Arbitrarily set to a default of 16 million entities.
/// Increasing this parameter does not increase real memory usage, only virtual.
/// </para>
/// </remarks>
public static readonly CVarDef<int> NetPvsEntityMax =
CVarDef.Create("net.pvs_entity_max", 1 << 24, CVar.ARCHIVE | CVar.SERVERONLY);
/// <summary>
/// If false, this will run more parts of PVS synchronously. This will generally slow it down, can be useful
/// for collecting tick timing metrics.
@@ -1031,6 +1055,12 @@ namespace Robust.Shared
* AUDIO
*/
/// <summary>
/// Default limit for concurrently playing an audio file.
/// </summary>
public static readonly CVarDef<int> AudioDefaultConcurrent =
CVarDef.Create("audio.default_concurrent", 16, CVar.CLIENTONLY | CVar.ARCHIVE);
public static readonly CVarDef<int> AudioAttenuation =
CVarDef.Create("audio.attenuation", (int) Attenuation.LinearDistanceClamped, CVar.REPLICATED | CVar.ARCHIVE);

View File

@@ -71,46 +71,6 @@ namespace Robust.Shared.Containers
[DataField("showEnts")]
public bool ShowContents { get; set; }
[Obsolete("Use container system method")]
public bool Insert(
EntityUid toinsert,
IEntityManager? entMan = null,
TransformComponent? transform = null,
TransformComponent? ownerTransform = null,
MetaDataComponent? meta = null,
PhysicsComponent? physics = null,
bool force = false)
{
IoCManager.Resolve(ref entMan);
return entMan.System<SharedContainerSystem>().Insert((toinsert, transform, meta, physics), this, ownerTransform, force);
}
/// <summary>
/// Whether the given entity can be inserted into this container.
/// </summary>
/// <param name="assumeEmpty">Whether to assume that the container is currently empty.</param>
protected internal virtual bool CanInsert(EntityUid toInsert, bool assumeEmpty, IEntityManager entMan) => true;
[Obsolete("Use container system method")]
public bool Remove(
EntityUid toRemove,
IEntityManager? entMan = null,
TransformComponent? xform = null,
MetaDataComponent? meta = null,
bool reparent = true,
bool force = false,
EntityCoordinates? destination = null,
Angle? localRotation = null
)
{
IoCManager.Resolve(ref entMan);
return entMan.System<SharedContainerSystem>().Remove((toRemove, xform, meta), this, reparent, force, destination, localRotation);
}
[Obsolete("Use container system method")]
public void ForceRemove(EntityUid toRemove, IEntityManager? entMan = null, MetaDataComponent? meta = null)
=> Remove(toRemove, entMan, meta: meta, reparent: false, force: true);
/// <summary>
/// Checks if the entity is contained in this container.
/// This is not recursive, so containers of children are not checked.
@@ -120,18 +80,10 @@ namespace Robust.Shared.Containers
public abstract bool Contains(EntityUid contained);
/// <summary>
/// Clears the container and marks it as deleted.
/// Whether the given entity can be inserted into this container.
/// </summary>
[Obsolete("use system method")]
public void Shutdown(IEntityManager? entMan = null, INetManager? _ = null)
{
IoCManager.Resolve(ref entMan);
entMan.System<SharedContainerSystem>().ShutdownContainer(this);
}
/// <inheritdoc />
[Access(typeof(SharedContainerSystem))]
protected internal abstract void InternalShutdown(IEntityManager entMan, SharedContainerSystem system, bool isClient);
/// <param name="assumeEmpty">Whether to assume that the container is currently empty.</param>
protected internal virtual bool CanInsert(EntityUid toInsert, bool assumeEmpty, IEntityManager entMan) => true;
/// <summary>
/// Implement to store the reference in whatever form you want
@@ -148,5 +100,14 @@ namespace Robust.Shared.Containers
/// <param name="entMan"></param>
[Access(typeof(SharedContainerSystem))]
protected internal abstract void InternalRemove(EntityUid toRemove, IEntityManager entMan);
/// <summary>
/// Implement to clear the container and mark it as deleted.
/// </summary>
/// <param name="entMan"></param>
/// <param name="system"></param>
/// <param name=isClient"></param>
[Access(typeof(SharedContainerSystem))]
protected internal abstract void InternalShutdown(IEntityManager entMan, SharedContainerSystem system, bool isClient);
}
}

View File

@@ -32,7 +32,7 @@ namespace Robust.Shared.ContentPack
String("short").ThenReturn(PrimitiveTypeCode.Int16);
private static readonly Parser<char, PrimitiveTypeCode> UInt16TypeParser =
String("ushort").ThenReturn(PrimitiveTypeCode.UInt32);
String("ushort").ThenReturn(PrimitiveTypeCode.UInt16);
private static readonly Parser<char, PrimitiveTypeCode> Int32TypeParser =
String("int").ThenReturn(PrimitiveTypeCode.Int32);

View File

@@ -84,12 +84,146 @@ Types:
- "bool get_HasContents()"
Lidgren.Network:
NetBuffer:
All: True
Methods:
- "byte[] get_Data()"
- "void set_Data(byte[])"
- "int get_LengthBytes()"
- "void set_LengthBytes(int)"
- "int get_LengthBits()"
- "void set_LengthBits(int)"
- "long get_Position()"
- "void set_Position(long)"
- "int get_PositionInBytes()"
- "byte[] PeekDataBuffer()"
- "bool PeekBoolean()"
- "byte PeekByte()"
- "sbyte PeekSByte()"
- "byte PeekByte(int)"
- "System.Span`1<byte> PeekBytes(System.Span`1<byte>)"
- "byte[] PeekBytes(int)"
- "void PeekBytes(byte[], int, int)"
- "short PeekInt16()"
- "ushort PeekUInt16()"
- "int PeekInt32()"
- "int PeekInt32(int)"
- "uint PeekUInt32()"
- "uint PeekUInt32(int)"
- "ulong PeekUInt64()"
- "long PeekInt64()"
- "ulong PeekUInt64(int)"
- "long PeekInt64(int)"
- "float PeekFloat()"
- "System.Half PeekHalf()"
- "float PeekSingle()"
- "double PeekDouble()"
- "string PeekString()"
- "int PeekStringSize()"
- "bool ReadBoolean()"
- "byte ReadByte()"
- "bool ReadByte(ref byte)"
- "sbyte ReadSByte()"
- "byte ReadByte(int)"
- "System.Span`1<byte> ReadBytes(System.Span`1<byte>)"
- "byte[] ReadBytes(int)"
- "bool ReadBytes(int, ref byte[])"
- "bool TryReadBytes(System.Span`1<byte>)"
- "void ReadBytes(byte[], int, int)"
- "void ReadBits(System.Span`1<byte>, int)"
- "void ReadBits(byte[], int, int)"
- "short ReadInt16()"
- "ushort ReadUInt16()"
- "int ReadInt32()"
- "bool ReadInt32(ref int)"
- "int ReadInt32(int)"
- "uint ReadUInt32()"
- "bool ReadUInt32(ref uint)"
- "uint ReadUInt32(int)"
- "ulong ReadUInt64()"
- "long ReadInt64()"
- "ulong ReadUInt64(int)"
- "long ReadInt64(int)"
- "float ReadFloat()"
- "System.Half ReadHalf()"
- "float ReadSingle()"
- "bool ReadSingle(ref float)"
- "double ReadDouble()"
- "uint ReadVariableUInt32()"
- "bool ReadVariableUInt32(ref uint)"
- "int ReadVariableInt32()"
- "long ReadVariableInt64()"
- "ulong ReadVariableUInt64()"
- "float ReadSignedSingle(int)"
- "float ReadUnitSingle(int)"
- "float ReadRangedSingle(float, float, int)"
- "int ReadRangedInteger(int, int)"
- "long ReadRangedInteger(long, long)"
- "string ReadString()"
- "bool ReadString(ref string)"
- "double ReadTime(Lidgren.Network.NetConnection, bool)"
- "System.Net.IPEndPoint ReadIPEndPoint()"
- "void SkipPadBits()"
- "void ReadPadBits()"
- "void SkipPadBits(int)"
- "void EnsureBufferSize(int)"
- "void Write(bool)"
- "void Write(byte)"
- "void WriteAt(int, byte)"
- "void Write(sbyte)"
- "void Write(byte, int)"
- "void Write(byte[])"
- "void Write(System.ReadOnlySpan`1<byte>)"
- "void Write(byte[], int, int)"
- "void Write(ushort)"
- "void WriteAt(int, ushort)"
- "void Write(ushort, int)"
- "void Write(short)"
- "void WriteAt(int, short)"
- "void Write(int)"
- "void WriteAt(int, int)"
- "void Write(uint)"
- "void WriteAt(int, uint)"
- "void Write(uint, int)"
- "void Write(int, int)"
- "void Write(ulong)"
- "void WriteAt(int, ulong)"
- "void Write(ulong, int)"
- "void Write(long)"
- "void Write(long, int)"
- "void Write(System.Half)"
- "void Write(float)"
- "void Write(double)"
- "int WriteVariableUInt32(uint)"
- "int WriteVariableInt32(int)"
- "int WriteVariableInt64(long)"
- "int WriteVariableUInt64(ulong)"
- "void WriteSignedSingle(float, int)"
- "void WriteUnitSingle(float, int)"
- "void WriteRangedSingle(float, float, float, int)"
- "int WriteRangedInteger(int, int, int)"
- "int WriteRangedInteger(long, long, long)"
- "void Write(string)"
- "void Write(System.Net.IPEndPoint)"
- "void WriteTime(bool)"
- "void WriteTime(double, bool)"
- "void WritePadBits()"
- "void WritePadBits(int)"
- "void Write(Lidgren.Network.NetBuffer)"
- "void Zero(int)"
- "void .ctor()"
NetDeliveryMethod: { }
NetIncomingMessage:
All: True
Methods:
- "Lidgren.Network.NetIncomingMessageType get_MessageType()"
- "Lidgren.Network.NetDeliveryMethod get_DeliveryMethod()"
- "int get_SequenceChannel()"
- "System.Net.IPEndPoint get_SenderEndPoint()"
- "Lidgren.Network.NetConnection get_SenderConnection()"
- "double get_ReceiveTime()"
- "double ReadTime(bool)"
- "string ToString()"
NetOutgoingMessage:
All: True
Methods:
- "string ToString()"
Nett:
CommentLocation: { } # Enum
Toml:

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading;
using Robust.Shared.Utility;
namespace Robust.Shared.ContentPack
@@ -135,11 +136,37 @@ namespace Robust.Shared.ContentPack
path = path.Directory;
var fullPath = GetFullPath(path);
Process.Start(new ProcessStartInfo
if (OperatingSystem.IsWindows())
{
UseShellExecute = true,
FileName = fullPath,
});
Process.Start(new ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = ".",
WorkingDirectory = fullPath,
});
}
else if (OperatingSystem.IsMacOS())
{
Process.Start(new ProcessStartInfo
{
FileName = "open",
Arguments = ".",
WorkingDirectory = fullPath,
});
}
else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
{
Process.Start(new ProcessStartInfo
{
FileName = "xdg-open",
Arguments = ".",
WorkingDirectory = fullPath,
});
}
else
{
throw new NotSupportedException("Opening OS windows not supported on this OS");
}
}
#endregion

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Robust.Shared.GameStates;
using Robust.Shared.Maths;
using Robust.Shared.Prototypes;
@@ -199,6 +200,11 @@ namespace Robust.Shared.GameObjects
// (Creation can still be cleared though)
ClearCreationTick();
}
/// <summary>
/// Offset into internal PVS data.
/// </summary>
internal PvsIndex PvsData;
}
[Flags]
@@ -221,16 +227,32 @@ namespace Robust.Shared.GameObjects
/// </summary>
Detached = 1 << 2,
/// <summary>
/// Indicates this entity can never be handled by the client as PVS detached.
/// </summary>
Undetachable = 1 << 3,
/// <summary>
/// If true, then this entity is considered a "high priority" entity and will be sent to players from further
/// away. Useful for things like light sources and occluders. Only works if the entity is directly parented to
/// a grid or map.
/// </summary>
PvsPriority = 1 << 3,
PvsPriority = 1 << 4,
}
/// <summary>
/// Key struct for uniquely identifying a PVS chunk.
/// </summary>
internal readonly record struct PvsChunkLocation(EntityUid Uid, Vector2i Indices);
/// <summary>
/// An opaque index into the PVS data arrays on the server.
/// </summary>
internal readonly record struct PvsIndex(int Index)
{
/// <summary>
/// An invalid index. This is also used as a marker value in the free list.
/// </summary>
public static readonly PvsIndex Invalid = new PvsIndex(-1);
}
}

View File

@@ -32,6 +32,7 @@ public partial class EntityManager
/// </summary>
internal void SetNetEntity(EntityUid uid, NetEntity netEntity, MetaDataComponent component)
{
DebugTools.Assert(component.NetEntity == NetEntity.Invalid || _netMan.IsClient);
DebugTools.Assert(!NetEntityLookup.ContainsKey(netEntity));
NetEntityLookup[netEntity] = (uid, component);
component.NetEntity = netEntity;

View File

@@ -906,6 +906,16 @@ namespace Robust.Shared.GameObjects
DebugTools.Assert("Why are you raising predictive events on the server?");
}
/// <summary>
/// Raises an event locally on client or networked on server.
/// </summary>
public abstract void RaiseSharedEvent<T>(T message, EntityUid? user = null) where T : EntityEventArgs;
/// <summary>
/// Raises an event locally on client or networked on server.
/// </summary>
public abstract void RaiseSharedEvent<T>(T message, ICommonSession? user = null) where T : EntityEventArgs;
/// <summary>
/// Factory for generating a new EntityUid for an entity currently being created.
/// </summary>

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Robust.Shared.Collections;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
@@ -744,9 +745,20 @@ public sealed partial class EntityLookupSystem
}
public HashSet<EntityUid> GetEntitiesInRange(EntityCoordinates coordinates, float range, LookupFlags flags = DefaultFlags)
{
var ents = new HashSet<EntityUid>();
GetEntitiesInRange(coordinates, range, ents, flags);
return ents;
}
public void GetEntitiesInRange(EntityCoordinates coordinates, float range, HashSet<EntityUid> entities, LookupFlags flags = DefaultFlags)
{
var mapPos = coordinates.ToMap(EntityManager, _transform);
return GetEntitiesInRange(mapPos, range, flags);
if (mapPos.MapId == MapId.Nullspace)
return;
GetEntitiesInRange(mapPos.MapId, mapPos.Position, range, entities, flags);
}
#endregion

View File

@@ -616,7 +616,7 @@ public abstract partial class SharedTransformSystem
TransformComponent? newParent = null,
TransformComponent? oldParent = null)
{
SetCoordinates((uid, xform, MetaData(uid)), value, rotation, unanchor, newParent, oldParent);
SetCoordinates((uid, xform, _metaQuery.GetComponent(uid)), value, rotation, unanchor, newParent, oldParent);
}
#endregion

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Robust.Shared.Network
{
@@ -31,9 +30,10 @@ namespace Robust.Shared.Network
/// </summary>
public sealed class NetConnectingArgs : EventArgs
{
public bool IsDenied => DenyReason != null;
public bool IsDenied => DenyReasonData != null;
public string? DenyReason { get; private set; }
public string? DenyReason => DenyReasonData?.Text;
public NetDenyReason? DenyReasonData { get; private set; }
public NetUserData UserData { get; }
@@ -48,7 +48,12 @@ namespace Robust.Shared.Network
public void Deny(string reason)
{
DenyReason = reason;
Deny(new NetDenyReason(reason));
}
public void Deny(NetDenyReason reason)
{
DenyReasonData = reason;
}
/// <summary>
@@ -65,12 +70,29 @@ namespace Robust.Shared.Network
}
}
/// <summary>
/// Contains a reason for denying a client connection to the game server.
/// </summary>
/// <param name="Text">The textual reason, presented to the user.</param>
/// <param name="AdditionalProperties">
/// Additional JSON properties that will be included in the <see cref="NetDisconnectMessage"/>.
/// Valid value types are: string, int, float, bool.
/// </param>
/// <seealso cref="NetDisconnectMessage"/>
/// <seealso cref="NetConnectingArgs"/>
public record NetDenyReason(string Text, Dictionary<string, object> AdditionalProperties)
{
public NetDenyReason(string Text) : this(Text, new Dictionary<string, object>())
{
}
}
/// <summary>
/// Structured reason common interface.
/// </summary>
public interface INetStructuredReason
{
JsonObject StructuredReason { get; }
NetDisconnectMessage Message { get; }
string Reason { get; }
bool RedialFlag { get; }
}
@@ -80,33 +102,33 @@ namespace Robust.Shared.Network
/// </summary>
public sealed class NetConnectFailArgs : EventArgs, INetStructuredReason
{
public NetConnectFailArgs(string reason) : this(NetStructuredDisconnectMessages.Decode(reason))
public NetConnectFailArgs(string reason) : this(NetDisconnectMessage.Decode(reason))
{
}
public NetConnectFailArgs(JsonObject reason)
internal NetConnectFailArgs(NetDisconnectMessage reason)
{
StructuredReason = reason;
Message = reason;
}
public JsonObject StructuredReason { get; }
public string Reason => NetStructuredDisconnectMessages.ReasonOf(StructuredReason);
public bool RedialFlag => NetStructuredDisconnectMessages.RedialFlagOf(StructuredReason);
public NetDisconnectMessage Message { get; }
public string Reason => Message.Reason;
public bool RedialFlag => Message.RedialFlag;
}
public sealed class NetDisconnectedArgs : NetChannelArgs, INetStructuredReason
{
public NetDisconnectedArgs(INetChannel channel, string reason) : this(channel, NetStructuredDisconnectMessages.Decode(reason))
public NetDisconnectedArgs(INetChannel channel, string reason) : this(channel, NetDisconnectMessage.Decode(reason))
{
}
public NetDisconnectedArgs(INetChannel channel, JsonObject reason) : base(channel)
internal NetDisconnectedArgs(INetChannel channel, NetDisconnectMessage reason) : base(channel)
{
StructuredReason = reason;
Message = reason;
}
public JsonObject StructuredReason { get; }
public string Reason => NetStructuredDisconnectMessages.ReasonOf(StructuredReason);
public bool RedialFlag => NetStructuredDisconnectMessages.RedialFlagOf(StructuredReason);
public NetDisconnectMessage Message { get; }
public string Reason => Message.Reason;
public bool RedialFlag => Message.RedialFlag;
}
}

View File

@@ -0,0 +1,259 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Robust.Shared.Utility;
namespace Robust.Shared.Network;
// Why did this dinky class grow to this LOC...
/// <summary>
/// Stores structured information about why a connection was denied or disconnected.
/// </summary>
/// <remarks>
/// <para>
/// The core networking layer (Lidgren) allows passing plain strings for disconnect reasons.
/// We can beam a structured format (like JSON) over this,
/// but Lidgren also generates messages internally (such as on timeout).
/// This class is responsible for bridging the two to produce consistent results.
/// </para>
/// <para>
/// Disconnect messages are just a simple key/value format.
/// Valid value types are <see cref="int"/>, <see cref="float"/>, <see cref="bool"/>, and <see cref="string"/>.
/// </para>
/// </remarks>
public sealed class NetDisconnectMessage
{
private const string LidgrenDisconnectedPrefix = "Disconnected: ";
/// <summary>
/// The reason given if none was included in the structured message.
/// </summary>
internal const string DefaultReason = "unknown reason";
/// <summary>
/// The default redial flag given if none was included in the structured message.
/// </summary>
internal const bool DefaultRedialFlag = false;
/// <summary>
/// The key of the <see cref="Reason"/> value.
/// </summary>
public const string ReasonKey = "reason";
/// <summary>
/// The key of the <see cref="RedialFlag"/> value.
/// </summary>
public const string RedialKey = "redial";
internal readonly Dictionary<string, object> Values;
internal NetDisconnectMessage(Dictionary<string, object> values)
{
Values = values;
}
internal NetDisconnectMessage(
string reason = DefaultReason,
bool redialFlag = DefaultRedialFlag)
{
Values = new Dictionary<string, object>
{
{ ReasonKey, reason },
{ RedialKey, redialFlag }
};
}
/// <summary>
/// The human-readable reason for why the disconnection happened.
/// </summary>
/// <seealso cref="ReasonKey"/>
public string Reason => StringOf(ReasonKey, DefaultReason);
/// <summary>
/// Whether the client should "redial" to reconnect to the server.
/// </summary>
/// <remarks>
/// Redial means the client gets restarted by the launcher, to enable an update to occur.
/// This is generally set if the disconnection reason is some sort of version mismatch.
/// </remarks>
/// <seealso cref="RedialKey"/>
public bool RedialFlag => BoolOf(RedialKey, DefaultRedialFlag);
/// <summary>
/// Decode from a disconnect message.
/// </summary>
/// <remarks>
/// <para>
/// If structured JSON can be extracted, it is used.
/// Otherwise, or if the format is invalid, the entire input is returned as disconnect reason.
/// </para>
/// <para>Invalid JSON values (e.g. arrays) are discarded.</para>
/// </remarks>
/// <param name="text">The disconnect reason from Lidgren's disconnect message.</param>
internal static NetDisconnectMessage Decode(string text)
{
var start = text.AsMemory().TrimStart();
// Lidgren generates this prefix internally.
if (start.Span.StartsWith(LidgrenDisconnectedPrefix))
start = start[LidgrenDisconnectedPrefix.Length..];
// If it starts with { it's probably a JSON object.
if (start.Span.StartsWith("{"))
{
try
{
using var node = JsonDocument.Parse(start);
DebugTools.Assert(node.RootElement.ValueKind == JsonValueKind.Object);
return JsonToReason(node.RootElement);
}
catch (Exception)
{
// Discard the exception
}
}
// Something went wrong. That probably means it's not a structured reason.
// Or worst case scenario, some poor end-user has to look at half-broken JSON.
return new NetDisconnectMessage(new Dictionary<string, object>
{
{ ReasonKey, text }
});
}
/// <summary>
/// Encode to a textual string, that can be embedded into a disconnect message.
/// </summary>
internal string Encode()
{
return JsonSerializer.Serialize(Values);
}
private static NetDisconnectMessage JsonToReason(JsonElement obj)
{
DebugTools.Assert(obj.ValueKind == JsonValueKind.Object);
var dict = new Dictionary<string, object>();
foreach (var property in obj.EnumerateObject())
{
object value;
switch (property.Value.ValueKind)
{
case JsonValueKind.String:
value = property.Value.GetString()!;
break;
case JsonValueKind.Number:
if (property.Value.TryGetInt32(out var valueInt))
value = valueInt;
else
value = property.Value.GetSingle();
break;
case JsonValueKind.True:
case JsonValueKind.False:
value = property.Value.GetBoolean();
break;
default:
// Discard invalid values intentionally.
continue;
}
dict[property.Name] = value;
}
return new NetDisconnectMessage(dict);
}
/// <summary>
/// Get a value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <returns>
/// Null if no such value exists, otherwise an object of one of the valid types (int, float, string, bool).
/// </returns>
public object? ValueOf(string key)
{
return Values.GetValueOrDefault(key);
}
/// <summary>
/// Get a <see cref="string"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <param name="defaultValue">Default value to return if the value does not exist or is the wrong type.</param>
/// <returns>
/// The <see cref="string"/> value with the given key,
/// or <paramref name="defaultValue"/> if no such value exists or it's a different type.
/// </returns>
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? StringOf(string key, string? defaultValue = null)
{
if (ValueOf(key) is not string valueString)
return defaultValue;
return valueString;
}
/// <summary>
/// Get a <see cref="bool"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <returns>
/// The <see cref="bool"/> value with the given key, or <see langword="null" /> if no such value exists or it's a different type.
/// </returns>
public bool? BoolOf(string key) => ValueOf(key) as bool?;
/// <summary>
/// Get a <see cref="bool"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <param name="defaultValue">Default value to return if the value does not exist or is the wrong type.</param>
/// <returns>
/// The <see cref="bool"/> value with the given key,
/// or <paramref name="defaultValue"/> if no such value exists or it's a different type.
/// </returns>
public bool BoolOf(string key, bool defaultValue) => BoolOf(key) ?? defaultValue;
/// <summary>
/// Get a <see cref="int"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <returns>
/// The <see cref="int"/> value with the given key, or <see langword="null" /> if no such value exists or it's a different type.
/// </returns>
public int? Int32Of(string key) => ValueOf(key) as int?;
/// <summary>
/// Get an <see cref="Int32"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <param name="defaultValue">Default value to return if the value does not exist or is the wrong type.</param>
/// <returns>
/// The <see cref="Int32"/> value with the given key,
/// or <paramref name="defaultValue"/> if no such value exists or it's a different type.
/// </returns>
public int Int32Of(string key, int defaultValue) => Int32Of(key) ?? defaultValue;
/// <summary>
/// Get a <see cref="float"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <returns>
/// The <see cref="float"/> value with the given key, or <see langword="null" /> if no such value exists or it's a different type.
/// </returns>
public float? SingleOf(string key)
{
var value = ValueOf(key);
return value as float? ?? value as int?;
}
/// <summary>
/// Get a <see cref="Single"/> value by its key.
/// </summary>
/// <param name="key">The key of the value to look up.</param>
/// <param name="defaultValue">Default value to return if the value does not exist or is the wrong type.</param>
/// <returns>
/// The <see cref="Single"/> value with the given key,
/// or <paramref name="defaultValue"/> if no such value exists or it's a different type.
/// </returns>
public float SingleOf(string key, float defaultValue) => SingleOf(key) ?? defaultValue;
}

View File

@@ -16,7 +16,7 @@ namespace Robust.Shared.Network
{
partial class NetManager
{
private readonly static string DisconnectReasonWrongKey = NetStructuredDisconnectMessages.Encode("Token decryption failed.\nPlease reconnect to this server from the launcher.", true);
private static readonly string DisconnectReasonWrongKey = new NetDisconnectMessage("Token decryption failed.\nPlease reconnect to this server from the launcher.", true).Encode();
private readonly byte[] _cryptoPrivateKey = new byte[CryptoBox.SecretKeyBytes];
@@ -211,9 +211,15 @@ namespace Robust.Shared.Network
var endPoint = connection.RemoteEndPoint;
var connect = await OnConnecting(endPoint, userData, type);
if (connect.IsDenied)
if (connect.DenyReasonData is { } deny)
{
connection.Disconnect($"Connection denied: {connect.DenyReason}");
var denyMsg = $"Connect denied: {deny.Text}";
var structured = new NetDisconnectMessage(denyMsg);
foreach (var (k, v) in deny.AdditionalProperties)
{
structured.Values[k] = v;
}
connection.Disconnect(structured.Encode());
return;
}

View File

@@ -1,119 +0,0 @@
using System;
using System.Net;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Robust.Shared.Network;
/// <summary>
/// Structured disconnection utilities.
/// These use JsonNode so that Content may add it's own data.
/// Note that to prevent encoding a NetStructuredDisco value within a NetStructuredDisco value,
/// these should be encoded at the "highest level".
/// Whatever generates the final "reason" value is responsible for performing NetStructuredDisco.Encode.
/// </summary>
public static class NetStructuredDisconnectMessages
{
public const string ReasonKey = "reason";
public const string RedialKey = "redial";
/// <summary>
/// Encodes a structured disconnect message into a JsonObject.
/// That can then be extended with additional properties.
/// </summary>
public static JsonObject EncodeObject(string text, bool redialFlag = false)
{
JsonObject obj = new();
obj[ReasonKey] = text;
obj[RedialKey] = redialFlag;
return obj;
}
/// <summary>
/// Encodes a structured disconnect message.
/// Note that using this kind of gets in the way of adding content properties.
/// </summary>
public static string Encode(string text, bool redialFlag = false) => Encode(EncodeObject(text, redialFlag));
/// <summary>
/// Encodes a structured disconnect message from a JsonObject.
/// </summary>
public static string Encode(JsonObject obj) => obj.ToJsonString();
/// <summary>
/// Decodes a structured disconnect message.
/// This is designed assuming the input isn't necessarily a structured disconnect message.
/// As such this will always produce output that can be passed to ReasonOf.
/// </summary>
public static JsonObject Decode(string text)
{
var start = text.AsSpan().TrimStart();
// Lidgren generates this prefix internally.
var lidgrenDisconnectedPrefix = "Disconnected: ";
if (start.StartsWith(lidgrenDisconnectedPrefix))
start = start.Slice(lidgrenDisconnectedPrefix.Length);
// If it starts with { it's probably a JSON object.
if (start.StartsWith("{"))
{
try
{
var node = JsonNode.Parse(new string(start));
if (node != null)
return (JsonObject)node;
}
catch (Exception)
{
// Discard the exception
}
}
// Something went wrong, so...
JsonObject fallback = new();
fallback[ReasonKey] = text;
return fallback;
}
/// <summary>
/// Get a property as a JsonValue.
/// </summary>
public static JsonValue? ValueOf(JsonObject obj, string key)
{
if (obj.TryGetPropertyValue(key, out var val))
if (val is JsonValue)
return ((JsonValue) val);
return null;
}
/// <summary>
/// Decode a string property.
/// </summary>
public static string StringOf(JsonObject obj, string key, string def)
{
var val = ValueOf(obj, key);
if (val != null && val.TryGetValue(out string? res))
return res;
return def;
}
/// <summary>
/// Grab the redial flag.
/// </summary>
public static bool BoolOf(JsonObject obj, string key, bool def)
{
var val = ValueOf(obj, key);
if (val != null && val.TryGetValue(out bool res))
return res;
return def;
}
/// <summary>
/// Decode a reason.
/// </summary>
public static string ReasonOf(JsonObject obj) => StringOf(obj, ReasonKey, "unknown reason");
/// <summary>
/// Grab the redial flag.
/// </summary>
public static bool RedialFlagOf(JsonObject obj) => BoolOf(obj, RedialKey, false);
}

View File

@@ -0,0 +1,338 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Robust.Shared.Maths;
using static TerraFX.Interop.Windows.Windows;
using static TerraFX.Interop.Windows.MEM;
using static TerraFX.Interop.Windows.PAGE;
namespace Robust.Shared.Utility;
/// <summary>
/// Implementation detail to store metrics for <see cref="ResizableMemoryRegion{T}"/>.
/// </summary>
internal static class ResizableMemoryRegionMetrics
{
public static readonly Meter Meter = new("Robust.Shared.Utility.ResizableMemoryRegion");
public const string GaugeName = "robust_resizable_memory_used_bytes";
}
// TODO: Proper implementation on Linux that uses mmap()/madvise()/mprotect().
/// <summary>
/// An unmanaged region of memory that can be dynamically resized without requiring copying.
/// </summary>
/// <remarks>
/// <para>
/// The maximum size of the memory region must be specified in the constructor. This reserves virtual memory for later,
/// but does not charge actual physical memory or commit charge and therefore costs nothing.
/// </para>
/// <para>
/// The "real" allocated memory region can be expanded by calling <see cref="Expand"/>.
/// This will increase resource consumption and make more memory available for use.
/// </para>
/// <para>
/// Allocated memory starts initialized to 0.
/// </para>
/// </remarks>
/// <typeparam name="T">The type of elements stored in the memory region.</typeparam>
internal sealed unsafe class ResizableMemoryRegion<T> : IDisposable where T : unmanaged
{
private static readonly UpDownCounter<long> MemoryUsageGauge =
ResizableMemoryRegionMetrics.Meter.CreateUpDownCounter<long>(
ResizableMemoryRegionMetrics.GaugeName,
"bytes",
"The amount of committed memory used by ResizableMemoryRegion<T> instances.",
new[] { new KeyValuePair<string, object?>("type", typeof(T).FullName) });
/// <summary>
/// The pointer to the start of the allocated memory region. Use with care!
/// </summary>
public T* BaseAddress { get; private set; }
/// <summary>
/// The maximum amount of elements that can be stored in this memory region.
/// </summary>
public int MaxSize { get; }
/// <summary>
/// The current space (in elements) commit that is directly accessible.
/// </summary>
public int CurrentSize { get; private set; }
/// <summary>
/// Create a new <see cref="ResizableMemoryRegion{T}"/> with a certain maximum and initial size.
/// </summary>
/// <param name="maxElementSize">The maximum amount of elements ever stored in this memory region.</param>
/// <param name="initialElementSize">
/// The initial amount of elements that will be immediately accessible without using <see cref="Expand"/>.
/// </param>
/// <exception cref="InvalidOperationException">
/// Thrown if the memory region is already initialized.
/// </exception>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="maxElementSize"/> is zero
/// or <paramref name="initialElementSize"/> is greater than <paramref name="maxElementSize"/>.
/// </exception>
public ResizableMemoryRegion(int maxElementSize, int initialElementSize = 0)
{
if (BaseAddress != null)
throw new InvalidOperationException("Memory region is already initialized!");
if (initialElementSize > maxElementSize)
throw new ArgumentException("initialSize must be smaller than maxSize");
if (maxElementSize == 0)
throw new ArgumentException("Cannot allocate a 0-byte memory region!");
var maxByteSize = checked((nuint)sizeof(T) * (nuint)maxElementSize);
if (OperatingSystem.IsWindows())
{
// On Windows, we MEM_RESERVE a large chunk of memory and then MEM_COMMIT it later when expanding.
BaseAddress = (T*)VirtualAlloc(null, maxByteSize, MEM_RESERVE, PAGE_NOACCESS);
if (BaseAddress == null)
Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
}
else
{
// Non-Windows systems use some form of overcommit,
// and therefore we don't need to separately reserve and commit memory.
// So we can just calloc() a fuck-huge chunk of memory and call it a day.
//
// It's important that we use calloc() and don't manually fill the memory region,
// as that will avoid immediately assigning unused memory pages.
//
// Note that we still pretend to client code that this works the same as on Windows,
// e.g. the memory is not writable until expanded into. We do this so that client code does not prematurely
// populate the memory, e.g. PVS code filling it with a free list.
BaseAddress = (T*)NativeMemory.AllocZeroed(maxByteSize);
// What about Linux with overcommit disabled? Not a real use case, ignored.
}
MaxSize = maxElementSize;
Expand(initialElementSize);
}
/// <summary>
/// Expand the committed space for this <see cref="ResizableMemoryRegion{T}"/> to have space for at least
/// <paramref name="newElementSize"/> elements.
/// </summary>
/// <remarks>
/// This operation happens without copying and existing references to the memory region remain valid.
/// </remarks>
/// <param name="newElementSize">The minimum amount of elements that should fit in the memory region.</param>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="newElementSize"/> is greater than <see cref="MaxSize"/>.
/// </exception>
public void Expand(int newElementSize)
{
ThrowIfDisposed();
if (newElementSize > MaxSize)
throw new ArgumentException("Cannot expand memory region past max size.", nameof(newElementSize));
if (newElementSize <= CurrentSize)
return;
var previousSize = CurrentSize;
var newByteSize = (nuint)sizeof(T) * (nuint)newElementSize;
if (OperatingSystem.IsWindows())
{
var ret = VirtualAlloc(BaseAddress, newByteSize, MEM_COMMIT, PAGE_READWRITE);
if (ret == null)
Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
}
else
{
// Nada. On overcommit systems we don't need to do anything.
}
CurrentSize = newElementSize;
MemoryUsageGauge.Add((newElementSize - previousSize) * sizeof(T));
}
/// <summary>
/// Shrink this <see cref="ResizableMemoryRegion{T}"/> to reduce the amount of memory used.
/// </summary>
/// <remarks>
/// Existing references inside the shrank-away region of memory become undefined to read or write from,
/// and can cause access violations.
/// </remarks>
/// <param name="newElementSize">
/// The new size of the committed region of memory.
/// </param>
/// <exception cref="ArgumentException">
/// Thrown if trying to shrink to a size larger than the current size, or when trying to shrink to a negative size.
/// </exception>
public void Shrink(int newElementSize)
{
ThrowIfDisposed();
if (newElementSize > CurrentSize)
throw new ArgumentException("Cannot shrink to a larger size!", nameof(newElementSize));
if (newElementSize < 0)
throw new ArgumentException("Cannot shrink to a negative size!", nameof(newElementSize));
var currentByteSize = (nuint)sizeof(T) * (nuint)CurrentSize;
var newByteSize = (nuint)sizeof(T) * (nuint)newElementSize;
// If the new max size cuts a page in the middle we can't free it so round up to the next page.
var newPageSize = MathHelper.CeilMultipleOfPowerOfTwo(newByteSize, (nuint) Environment.SystemPageSize);
if (OperatingSystem.IsWindows())
{
var freeBaseAddress = (byte*)BaseAddress + newPageSize;
var freeLength = currentByteSize - newPageSize;
var result = VirtualFree(freeBaseAddress, freeLength, MEM_DECOMMIT);
if (!result)
Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
}
else
{
// Nothing to do on operating systems without advanced memory management.
}
CurrentSize = newElementSize;
}
/// <summary>
/// Get a <see cref="Span{T}"/> over the committed region of memory.
/// </summary>
/// <returns>A <see cref="Span{T}"/> over the committed region of memory.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<T> GetSpan()
{
// If the memory region is disposed, CurrentSize is 0 so it's impossible to use the (nullptr) BaseAddress.
// This means you can't dereference the span anyways, so that works out fine!
return new Span<T>(BaseAddress, CurrentSize);
}
/// <summary>
/// Get a <see cref="Span{T}"/> over the committed region of memory, cast to a different type.
/// </summary>
/// <remarks>
/// This is equivalent to using <see cref="MemoryMarshal.Cast{TFrom, TTo}(Span{TFrom})"/> on the result of <see cref="GetSpan"/>.
/// </remarks>
/// <typeparam name="TCast">The type to cast the memory region to.</typeparam>
/// <returns>A <see cref="Span{T}"/> over the committed region of memory.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<TCast> GetSpan<TCast>() where TCast : unmanaged
{
return MemoryMarshal.Cast<T, TCast>(GetSpan());
}
/// <summary>
/// Get a mutable reference to a single element of the memory region.
/// </summary>
/// <param name="index">The index of the element desired.</param>
/// <exception cref="IndexOutOfRangeException">
/// Thrown if <paramref name="index"/> is greater or equal to <see cref="CurrentSize"/>.
/// </exception>
/// <returns>A mutable reference to the element.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref T GetRef(int index)
{
// If the memory region is disposed, CurrentSize is 0 and this check always fails.
if (index >= CurrentSize)
ThrowIndexOutOfRangeException();
return ref *(BaseAddress + index);
}
/// <summary>
/// Get a mutable reference to a single element of the memory region, cast to a different type.
/// </summary>
/// <remarks>
/// This is equivalent to using <see cref="M:System.Runtime.CompilerServices.Unsafe.As``2(``0@)"/> on the result of <see cref="GetSpan"/>.
/// </remarks>
/// <param name="index">The index of the element desired.</param>
/// <exception cref="IndexOutOfRangeException">
/// Thrown if <paramref name="index"/> is greater or equal to <see cref="CurrentSize"/>.
/// </exception>
/// <returns>A mutable reference to the element.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ref TCast GetRef<TCast>(int index) where TCast : unmanaged
{
return ref Unsafe.As<T, TCast>(ref GetRef(index));
}
/// <summary>
/// Clear the contents of the memory stored in this <see cref="ResizableMemoryRegion{T}"/> back to zero.
/// </summary>
/// <remarks>
/// This does not change the <see cref="CurrentSize"/>.
/// </remarks>
public void Clear()
{
GetSpan().Clear();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ThrowIfDisposed()
{
if (BaseAddress == null)
ThrowNotInitialized();
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowNotInitialized()
{
throw new InvalidOperationException("Memory region is not initialized!");
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowIndexOutOfRangeException()
{
throw new IndexOutOfRangeException();
}
private void ReleaseUnmanagedResources()
{
if (BaseAddress == null)
return;
if (OperatingSystem.IsWindows())
{
var result = VirtualFree(BaseAddress, 0, MEM_RELEASE);
if (!result)
Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
}
else
{
NativeMemory.Free(BaseAddress);
}
MemoryUsageGauge.Add(-CurrentSize * sizeof(T));
BaseAddress = null;
CurrentSize = 0;
}
/// <summary>
/// Release the backing memory for this <see cref="ResizableMemoryRegion{T}"/>.
/// </summary>
/// <remarks>
/// Existing references to memory of the memory region become invalid and should no longer be used.
/// </remarks>
public void Dispose()
{
ReleaseUnmanagedResources();
GC.SuppressFinalize(this);
}
~ResizableMemoryRegion()
{
ReleaseUnmanagedResources();
}
}

View File

@@ -0,0 +1,52 @@
using System.Numerics;
using NUnit.Framework;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Map;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Robust.UnitTesting.Client.UserInterface.Controls;
[TestFixture]
[TestOf(typeof(TextEdit))]
public sealed class TextEditTest : RobustUnitTest
{
public override UnitTestProject Project => UnitTestProject.Client;
[OneTimeSetUp]
public void Setup()
{
IoCManager.Resolve<IUserInterfaceManagerInternal>().InitializeTesting();
}
// Regression test for https://github.com/space-wizards/RobustToolbox/issues/4953
// It was possible to move the cursor up/down if there was multi-line placeholder text.
[Test]
public void TestInvalidMoveInPlaceholder()
{
var textEdit = new TextEdit { Placeholder = new Rope.Leaf("Foo\nBar") };
textEdit.Arrange(new UIBox2(0, 0, 200, 200));
var click = new GUIBoundKeyEventArgs(EngineKeyFunctions.TextCursorDown, BoundKeyState.Down, new ScreenCoordinates(), true, Vector2.Zero, Vector2.Zero);
textEdit.KeyBindDown(click);
textEdit.KeyBindUp(click);
Assert.That(textEdit.CursorPosition.Index, Is.Zero);
}
// Regression test for https://github.com/space-wizards/RobustToolbox/issues/4957
// Moving left (with the arrow keys) in an empty TextEdit would cause an exception.
[Test]
public void TestEmptyMoveLeft()
{
var textEdit = new TextEdit();
textEdit.Arrange(new UIBox2(0, 0, 200, 200));
var click = new GUIBoundKeyEventArgs(EngineKeyFunctions.TextCursorLeft, BoundKeyState.Down, new ScreenCoordinates(), true, Vector2.Zero, Vector2.Zero);
textEdit.KeyBindDown(click);
textEdit.KeyBindUp(click);
}
}

View File

@@ -139,4 +139,3 @@ public sealed class GridDeleteSingleTileRemoveTestTest : RobustIntegrationTest
Assert.That(cQuery.GetComponent(cEntity.Value).ParentUid, Is.EqualTo(cMap));
}
}

View File

@@ -0,0 +1,113 @@
using NUnit.Framework;
using Robust.Shared.Network;
namespace Robust.UnitTesting.Shared.Networking;
[TestFixture]
[Parallelizable(ParallelScope.All)]
[TestOf(typeof(NetDisconnectMessage))]
internal sealed class NetDisconnectMessageTest
{
[Test]
[TestCase("Disconnected: bye", "Disconnected: bye", NetDisconnectMessage.DefaultRedialFlag)]
[TestCase("Disconnected: {\"reason\": \"bye\"}", "bye", NetDisconnectMessage.DefaultRedialFlag)]
[TestCase("Disconnected: {}", NetDisconnectMessage.DefaultReason, NetDisconnectMessage.DefaultRedialFlag)]
[TestCase("Disconnected: {\"redial\": true}", NetDisconnectMessage.DefaultReason, true)]
[TestCase("Disconnected: {\"redial\": true, \"foobar\": 5}", NetDisconnectMessage.DefaultReason, true)]
[TestCase("Disconnected: {\"redial\": true, \"foobar\": 5, \"reason\": \"asdf\"}", "asdf", true)]
[TestCase("Disconnected: {", "Disconnected: {", NetDisconnectMessage.DefaultRedialFlag)]
[TestCase("Disconnected: {\"a\":[]}", NetDisconnectMessage.DefaultReason, NetDisconnectMessage.DefaultRedialFlag)]
[TestCase("{\"redial\": true, \"foobar\": 5, \"reason\": \"asdf\"}", "asdf", true)]
public void TestBasicDecode(string encoded, string reasonExpected, bool redialExpected)
{
var parsed = NetDisconnectMessage.Decode(encoded);
Assert.Multiple(() =>
{
Assert.That(parsed.Reason, Is.EqualTo(reasonExpected));
Assert.That(parsed.RedialFlag, Is.EqualTo(redialExpected));
});
}
[Test]
public void TestEncode()
{
var value = new NetDisconnectMessage("foobar", true)
{
Values =
{
["asdf"] = 20.5f
}
};
var encoded = value.Encode();
TestContext.Write($"Encoded: {encoded}\n");
var decodedAgain = NetDisconnectMessage.Decode(encoded);
Assert.Multiple(() =>
{
Assert.That(decodedAgain.Reason, Is.EqualTo("foobar"));
Assert.That(decodedAgain.RedialFlag, Is.EqualTo(true));
Assert.That(decodedAgain.SingleOf("asdf"), Is.EqualTo(20.5f));
});
}
[Test]
public void TestDefaultConstructor()
{
var value = new NetDisconnectMessage("foobar");
Assert.Multiple(() =>
{
Assert.That(value.Reason, Is.EqualTo("foobar"));
Assert.That(value.RedialFlag, Is.EqualTo(false));
});
}
[Test]
public void TestValueOfInt()
{
var parsed = NetDisconnectMessage.Decode("{\"foobar\": 5}");
Assert.Multiple(() =>
{
Assert.That(parsed.Int32Of("foobar"), Is.EqualTo(5));
Assert.That(parsed.Int32Of("asdf"), Is.Null);
Assert.That(parsed.Int32Of("asdf", 7), Is.EqualTo(7));
});
}
[Test]
public void TestValueOfFloat()
{
var parsed = NetDisconnectMessage.Decode("{\"foobar\": 5.5}");
Assert.Multiple(() =>
{
Assert.That(parsed.SingleOf("foobar"), Is.EqualTo(5.5f));
Assert.That(parsed.SingleOf("asdf"), Is.Null);
Assert.That(parsed.SingleOf("asdf", 7), Is.EqualTo(7f));
});
}
[Test]
public void TestValueOfFloatInt()
{
var parsed = NetDisconnectMessage.Decode("{\"foobar\": 5}");
Assert.That(parsed.SingleOf("foobar"), Is.EqualTo(5f));
}
[Test]
public void TestValueOfString()
{
var parsed = NetDisconnectMessage.Decode("{\"foobar\": \"real\"}");
Assert.Multiple(() =>
{
Assert.That(parsed.StringOf("foobar"), Is.EqualTo("real"));
Assert.That(parsed.StringOf("asdf"), Is.Null);
Assert.That(parsed.StringOf("asdf", "honk"), Is.EqualTo("honk"));
});
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using NUnit.Framework;
using Robust.Shared.Maths;
@@ -304,5 +305,23 @@ namespace Robust.UnitTesting.Shared.Utility
Assert.That(MathHelper.Mod(val, mod), Is.EqualTo(result).Within(0.00000000001));
}
[Test]
[TestCase(4, 4, ExpectedResult = 4)]
[TestCase(5, 4, ExpectedResult = 8)]
[TestCase(7, 4, ExpectedResult = 8)]
[TestCase(8, 4, ExpectedResult = 8)]
[TestCase(4U, 4U, ExpectedResult = 4U)]
[TestCase(5U, 4U, ExpectedResult = 8U)]
[TestCase(7U, 4U, ExpectedResult = 8U)]
[TestCase(8U, 4U, ExpectedResult = 8U)]
[TestCase(4L, 4L, ExpectedResult = 4L)]
[TestCase(5L, 4L, ExpectedResult = 8L)]
[TestCase(7L, 4L, ExpectedResult = 8L)]
[TestCase(8L, 4L, ExpectedResult = 8L)]
public T TestCeilMultipleOfPowerOfTwo<T>(T value, T powerOfTwo) where T : IBinaryInteger<T>
{
return MathHelper.CeilMultipleOfPowerOfTwo(value, powerOfTwo);
}
}
}