baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

25
src/.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

24
src/.editorconfig Normal file
View File

@@ -0,0 +1,24 @@
[*.cs]
dotnet_diagnostic.CS1998.severity = suggestion
# SA1309: Field names should not begin with underscore
dotnet_diagnostic.SA1309.severity = none
# SA1516: Elements should be separated by blank line
dotnet_diagnostic.SA1516.severity = none
# SA1615: Element return value should be documented
dotnet_diagnostic.SA1615.severity = suggestion
# VSTHRD103: Call async methods when in an async method
dotnet_diagnostic.VSTHRD103.severity = error
# VSTHRD111: Use ConfigureAwait(bool)
dotnet_diagnostic.VSTHRD111.severity = warning
[*.xml]
tab_width = 2
indent_size = 2
end_of_line = crlf

View File

@@ -0,0 +1,14 @@
// <copyright file="CloneableAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Annotations;
/// <summary>
/// Classes marked with this attribute will get a generic Clonable-Interface
/// implemented by a code generator.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class CloneableAttribute : Attribute
{
}

View File

@@ -0,0 +1,14 @@
// <copyright file="IgnoreWhenCloningAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Annotations;
/// <summary>
/// Properties marked with this attribute will not get cloned
/// implemented by a code generator.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public sealed class IgnoreWhenCloningAttribute : Attribute
{
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,31 @@
// <copyright file="AggregateType.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The attribute aggregate type.
/// </summary>
public enum AggregateType
{
/// <summary>
/// Adds the value to the raw base value.
/// </summary>
AddRaw,
/// <summary>
/// Multiplicates the raw base value.
/// </summary>
Multiplicate,
/// <summary>
/// Adds the value to the final value.
/// </summary>
AddFinal,
/// <summary>
/// Adds only the highest available value to the raw base value (jewelry element resistance).
/// </summary>
Maximum,
}

View File

@@ -0,0 +1,128 @@
// <copyright file="AttributeDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// Defines and Identifies a Attribute.
/// In the future it may also contain additional data, like a maximum limit of the reachable value to do balancing.
/// </summary>
public class AttributeDefinition : IEquatable<AttributeDefinition>
{
/// <summary>
/// Initializes a new instance of the <see cref="AttributeDefinition"/> class.
/// </summary>
public AttributeDefinition()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AttributeDefinition"/> class.
/// </summary>
/// <param name="id">The identifier.</param>
/// <param name="designation">The designation.</param>
/// <param name="description">The description.</param>
public AttributeDefinition(Guid id, string designation, string description)
{
this.Id = id;
this.Designation = designation;
this.Description = description;
}
/// <summary>
/// Gets or sets the identifier.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the designation.
/// </summary>
public string? Designation { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the maximum value of this attribute, if the value should be capped.
/// </summary>
public float? MaximumValue { get; set; }
/// <summary>
/// Implements the operator ==.
/// </summary>
/// <param name="lhs">The LHS.</param>
/// <param name="rhs">The RHS.</param>
/// <returns>
/// The result of the operator.
/// </returns>
public static bool operator ==(AttributeDefinition? lhs, AttributeDefinition? rhs)
{
if (ReferenceEquals(lhs, rhs))
{
return true;
}
if (lhs is null || rhs is null)
{
return false;
}
return lhs.Equals(rhs);
}
/// <summary>
/// Implements the operator !=.
/// </summary>
/// <param name="lhs">The LHS.</param>
/// <param name="rhs">The RHS.</param>
/// <returns>
/// The result of the operator.
/// </returns>
public static bool operator !=(AttributeDefinition? lhs, AttributeDefinition? rhs)
{
return !(lhs == rhs);
}
/// <summary>
/// Returns a <see cref="string" /> that represents this instance.
/// </summary>
/// <returns>
/// A <see cref="string" /> that represents this instance.
/// </returns>
public override string? ToString()
{
return this.Designation;
}
/// <summary>
/// Determines whether the specified <see cref="object" />, is equal to this instance.
/// </summary>
/// <param name="obj">The <see cref="object" /> to compare with this instance.</param>
/// <returns>
/// <c>true</c> if the specified <see cref="object" /> is equal to this instance; otherwise, <c>false</c>.
/// </returns>
public override bool Equals(object? obj)
{
return this.Equals(obj as AttributeDefinition);
}
/// <inheritdoc />
public bool Equals(AttributeDefinition? other)
{
return this.Id == other?.Id;
}
/// <summary>
/// Returns a hash code for this instance.
/// </summary>
/// <returns>
/// A hash code for this instance, suitable for use in hashing algorithms and data structures like a hash table.
/// </returns>
public override int GetHashCode()
{
return this.Id.GetHashCode();
}
}

View File

@@ -0,0 +1,162 @@
// <copyright file="AttributeRelationship.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
using System.Globalization;
/// <summary>
/// The operator which is applied between the input attribute and the input operand.
/// </summary>
public enum InputOperator
{
/// <summary>
/// The <see cref="AttributeRelationship.InputAttribute"/> is multiplied with the <see cref="AttributeRelationship.InputOperand"/> before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
Multiply,
/// <summary>
/// The <see cref="AttributeRelationship.InputAttribute"/> is increased by the <see cref="AttributeRelationship.InputOperand"/> before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
Add,
/// <summary>
/// The <see cref="AttributeRelationship.InputAttribute"/> is exponentiated by the <see cref="AttributeRelationship.InputOperand"/> before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
Exponentiate,
/// <summary>
/// The <see cref="AttributeRelationship.InputOperand"/> is exponentiated by the <see cref="AttributeRelationship.InputAttribute"/> before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
ExponentiateByAttribute,
/// <summary>
/// The maximum between <see cref="AttributeRelationship.InputAttribute"/> and <see cref="AttributeRelationship.InputOperand"/> is taken before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
Maximum,
/// <summary>
/// The minimum between <see cref="AttributeRelationship.InputAttribute"/> and <see cref="AttributeRelationship.InputOperand"/> is taken before effecting the <see cref="AttributeRelationship.TargetAttribute"/>.
/// </summary>
Minimum,
}
/// <summary>
/// Describes a relationship between two attributes.
/// </summary>
public class AttributeRelationship
{
private AttributeDefinition? _targetAttribute;
private AttributeDefinition? _inputAttribute;
private AttributeDefinition? _operandAttribute;
/// <summary>
/// Initializes a new instance of the <see cref="AttributeRelationship"/> class.
/// </summary>
public AttributeRelationship()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AttributeRelationship" /> class.
/// </summary>
/// <param name="targetAttribute">The target attribute.</param>
/// <param name="inputOperand">The multiplier.</param>
/// <param name="inputAttribute">The input attribute.</param>
/// <param name="aggregateType">The type of the aggregate on the <paramref name="targetAttribute"/>.</param>
public AttributeRelationship(AttributeDefinition targetAttribute, float inputOperand, AttributeDefinition inputAttribute, AggregateType aggregateType = AggregateType.AddRaw)
: this(targetAttribute, inputOperand, inputAttribute, InputOperator.Multiply, default, aggregateType)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AttributeRelationship" /> class.
/// </summary>
/// <param name="targetAttribute">The target attribute.</param>
/// <param name="inputOperand">The multiplier.</param>
/// <param name="inputAttribute">The input attribute.</param>
/// <param name="aggregateType">The type of the aggregate on the <paramref name="targetAttribute"/>.</param>
public AttributeRelationship(AttributeDefinition targetAttribute, AttributeDefinition inputOperand, AttributeDefinition inputAttribute, AggregateType aggregateType = AggregateType.AddRaw)
: this(targetAttribute, 1, inputAttribute, InputOperator.Multiply, inputOperand, aggregateType)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AttributeRelationship" /> class.
/// </summary>
/// <param name="targetAttribute">The target attribute.</param>
/// <param name="inputOperand">The multiplier.</param>
/// <param name="inputAttribute">The input attribute.</param>
/// <param name="inputOperator">The input operator.</param>
/// <param name="operandAttribute">The operand attribute.</param>
/// <param name="aggregateType">The type of the aggregate on the <paramref name="targetAttribute"/>.</param>
public AttributeRelationship(AttributeDefinition targetAttribute, float inputOperand, AttributeDefinition inputAttribute, InputOperator inputOperator, AttributeDefinition? operandAttribute = null, AggregateType aggregateType = AggregateType.AddRaw)
{
this.InputOperand = inputOperand;
this.InputOperator = inputOperator;
this.AggregateType = aggregateType;
this._targetAttribute = targetAttribute;
this._inputAttribute = inputAttribute;
this._operandAttribute = operandAttribute;
}
/// <summary>
/// Gets or sets the target attribute which will be affected.
/// </summary>
public virtual AttributeDefinition? TargetAttribute
{
get => this._targetAttribute;
set => this._targetAttribute = value;
}
/// <summary>
/// Gets or sets the input attribute which provides the input value.
/// </summary>
public virtual AttributeDefinition? InputAttribute
{
get => this._inputAttribute;
set => this._inputAttribute = value;
}
/// <summary>
/// Gets or sets the operand attribute which replaces the <see cref="InputOperand"/>, if set.
/// </summary>
public virtual AttributeDefinition? OperandAttribute
{
get => this._operandAttribute;
set => this._operandAttribute = value;
}
/// <summary>
/// Gets or sets the operator which is applied between the input attribute and the input operand.
/// </summary>
public InputOperator InputOperator { get; set; }
/// <summary>
/// Gets or sets the operand which is applied to the input attribute before adding to the target attribute.
/// Has only effect, when <see cref="OperandAttribute"/> is <see langword="null"/>.
/// </summary>
public float InputOperand { get; set; }
/// <summary>
/// Gets or sets the aggregate type with which the relationship should effect the target attribute.
/// </summary>
public AggregateType AggregateType { get; set; }
/// <inheritdoc/>
public override string ToString()
{
if (this.TargetAttribute is null)
{
return $"{this.InputAttribute} {this.InputOperator.AsString()} {this.OperandAttribute?.ToString() ?? this.InputOperand.ToString(CultureInfo.InvariantCulture)}";
}
if (this.InputOperator == InputOperator.ExponentiateByAttribute)
{
return $"{this.TargetAttribute} {(this.AggregateType == AggregateType.Multiplicate ? "*" : "+")}= {this.OperandAttribute?.ToString() ?? this.InputOperand.ToString(CultureInfo.InvariantCulture)} {this.InputOperator.AsString()} {this.InputAttribute}";
}
return $"{this.TargetAttribute} {(this.AggregateType == AggregateType.Multiplicate ? "*" : "+")}= {this.InputAttribute} {this.InputOperator.AsString()} {this.OperandAttribute?.ToString() ?? this.InputOperand.ToString(CultureInfo.InvariantCulture)}";
}
}

View File

@@ -0,0 +1,89 @@
// <copyright file="AttributeRelationshipElement.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute relationship element which takes several input elements which are summed up and multiplied.
/// Calculated values are cached for a better performance.
/// </summary>
public class AttributeRelationshipElement : SimpleElement
{
private float? _cachedValue;
/// <summary>
/// Initializes a new instance of the <see cref="AttributeRelationshipElement" /> class.
/// </summary>
/// <param name="inputElements">The input elements which are summed up.</param>
/// <param name="inputOperand">The operand which is applied to the summed up input elements.</param>
/// <param name="inputOperator">The input operator.</param>
public AttributeRelationshipElement(IEnumerable<IElement> inputElements, IElement inputOperand, InputOperator inputOperator)
{
this.InputElements = inputElements;
this.InputOperand = inputOperand;
this.InputOperator = inputOperator;
foreach (var element in this.InputElements)
{
element.ValueChanged += this.ElementChanged;
}
inputOperand.ValueChanged += this.ElementChanged;
// TODO: Is Dispose required?
}
/// <summary>
/// Gets the input elements.
/// </summary>
public IEnumerable<IElement> InputElements { get; }
/// <summary>
/// Gets or sets the multiplier with which the sum of all input element values are multiplied.
/// </summary>
public IElement InputOperand { get; set; }
/// <summary>
/// Gets or sets the input operator.
/// </summary>
public InputOperator InputOperator { get; set; }
/// <summary>
/// Gets the calculated value.
/// </summary>
public override float Value => this._cachedValue ?? this.GetAndCacheValue();
private void ElementChanged(object? sender, EventArgs eventArgs)
{
this._cachedValue = null;
this.RaiseValueChanged();
}
private float GetAndCacheValue()
{
this._cachedValue = this.CalculateValue();
return this._cachedValue.Value;
}
private float CalculateValue()
{
return this.InputOperator switch
{
InputOperator.Multiply => this.InputElements.Sum(a => a.Value) * this.InputOperand.Value,
InputOperator.Add => this.InputElements.Sum(a => a.Value) + this.InputOperand.Value,
InputOperator.Exponentiate => (float)Math.Pow(
this.InputElements.Sum(a => a.Value),
this.InputOperand.Value),
InputOperator.ExponentiateByAttribute => (float)Math.Pow(
this.InputOperand.Value,
this.InputElements.Sum(a => a.Value)),
InputOperator.Maximum => Math.Max(
this.InputElements.Sum(a => a.Value),
this.InputOperand.Value),
InputOperator.Minimum => Math.Min(
this.InputElements.Sum(a => a.Value),
this.InputOperand.Value),
_ => throw new InvalidOperationException($"Input operator {this.InputOperator} unknown"),
};
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="AttributeRelationshipExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// Extensions for <see cref="AttributeRelationship"/>.
/// </summary>
public static class AttributeRelationshipExtensions
{
/// <summary>
/// Gets the target attribute and throws an exception if it's not initialized yet.
/// </summary>
/// <param name="relationship">The attribute relationship.</param>
/// <returns>The target attribute definition.</returns>
/// <exception cref="InvalidOperationException">TargetAttribute not initialized.</exception>
public static AttributeDefinition GetTargetAttribute(this AttributeRelationship relationship)
{
return relationship.TargetAttribute ?? throw new InvalidOperationException("TargetAttribute not initialized.");
}
/// <summary>
/// Gets the input attribute and throws an exception if it's not initialized yet.
/// </summary>
/// <param name="relationship">The attribute relationship.</param>
/// <returns>The input attribute definition.</returns>
/// <exception cref="InvalidOperationException">TargetAttribute not initialized.</exception>
public static AttributeDefinition GetInputAttribute(this AttributeRelationship relationship)
{
return relationship.InputAttribute ?? throw new InvalidOperationException("InputAttribute not initialized.");
}
/// <summary>
/// Gets the operand attribute.
/// </summary>
/// <param name="relationship">The attribute relationship.</param>
/// <param name="attributeSystem">The attribute system.</param>
/// <returns>
/// The input attribute definition.
/// </returns>
public static IElement GetOperandElement(this AttributeRelationship relationship, IAttributeSystem attributeSystem)
{
if (relationship.OperandAttribute is { } operandAttribute)
{
return attributeSystem.GetOrCreateAttribute(operandAttribute);
}
return new ConstantElement(relationship.InputOperand);
}
}

View File

@@ -0,0 +1,259 @@
// <copyright file="AttributeSystem.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
using System.Collections;
/// <summary>
/// The attribute system which holds all attributes of a character.
/// </summary>
public class AttributeSystem : IAttributeSystem, IEnumerable<IAttribute>
{
private readonly IDictionary<AttributeDefinition, IAttribute> _attributes = new Dictionary<AttributeDefinition, IAttribute>();
/// <summary>
/// Initializes a new instance of the <see cref="AttributeSystem" /> class.
/// </summary>
/// <param name="statAttributes">The stat attributes. These attributes are added just as-is and are not wrapped by a <see cref="ComposableAttribute"/>.</param>
/// <param name="baseAttributes">The initial base attributes. These attributes contain the base values which will be wrapped by a <see cref="ComposableAttribute"/>, so additional elements can contribute to the attributes value. Instead of providing them here, you could also add them to the system by calling <see cref="AddElement"/> later.</param>
/// <param name="attributeRelationships">The initial attribute relationships. Instead of providing them here, you could also add them to the system by calling <see cref="AddAttributeRelationship(AttributeRelationship, IAttributeSystem, AggregateType)"/> later.</param>
public AttributeSystem(IEnumerable<IAttribute> statAttributes, IEnumerable<IAttribute> baseAttributes, IEnumerable<AttributeRelationship> attributeRelationships)
{
foreach (var statAttribute in statAttributes)
{
this._attributes.Add(statAttribute.Definition, statAttribute);
}
foreach (var baseAttribute in baseAttributes)
{
this.AddElement(baseAttribute, baseAttribute.Definition);
}
foreach (var combination in attributeRelationships)
{
this.AddAttributeRelationship(combination);
}
}
/// <inheritdoc/>
public float this[AttributeDefinition? attributeDefinition]
{
get => this.GetValueOfAttribute(attributeDefinition);
set => this.SetStatAttribute(attributeDefinition, value);
}
/// <inheritdoc/>
public void AddAttributeRelationship(AttributeRelationship relationship, IAttributeSystem sourceAttributeHolder, AggregateType aggregateType)
{
if (this.GetOrCreateAttribute(relationship.GetTargetAttribute()) is IComposableAttribute targetAttribute)
{
var relatedElement = this.CreateRelatedAttribute(relationship, sourceAttributeHolder, aggregateType);
targetAttribute.AddElement(relatedElement);
}
}
/// <summary>
/// Creates the related attribute.
/// </summary>
/// <param name="relationship">The relationship.</param>
/// <param name="sourceAttributeHolder">The source attribute holder. This may be the attribute system of another player.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
/// <returns>
/// The newly created relationship element.
/// </returns>
public IElement CreateRelatedAttribute(AttributeRelationship relationship, IAttributeSystem sourceAttributeHolder, AggregateType aggregateType)
{
var inputElements = new[] { sourceAttributeHolder.GetOrCreateAttribute(relationship.GetInputAttribute()) };
return new AttributeRelationshipElement(inputElements, relationship.GetOperandElement(sourceAttributeHolder), relationship.InputOperator)
{
AggregateType = aggregateType,
};
}
/// <summary>
/// Sets the stat attribute, if the <paramref name="attributeDefinition"/> is a stat attribute.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <param name="newValue">The new value.</param>
/// <returns>The success.</returns>
public bool SetStatAttribute(AttributeDefinition? attributeDefinition, float newValue)
{
if (attributeDefinition is null)
{
return false;
}
if (this._attributes.TryGetValue(attributeDefinition, out var attribute)
&& attribute is StatAttribute statAttribute)
{
statAttribute.Value = newValue;
return true;
}
return false;
}
/// <summary>
/// Gets the composable attribute.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <returns>The composable attribute.</returns>
public ComposableAttribute? GetComposableAttribute(AttributeDefinition attributeDefinition)
{
return this.GetOrCreateAttribute(attributeDefinition) as ComposableAttribute;
}
/// <inheritdoc/>
public float GetValueOfAttribute(AttributeDefinition? attributeDefinition)
{
var element = this.GetAttribute(attributeDefinition);
if (element != null)
{
var actualDefinition = (element as BaseAttribute)?.Definition ?? attributeDefinition;
if (actualDefinition?.MaximumValue is { } maximumValue && element.Value > maximumValue)
{
return maximumValue;
}
return element.Value;
}
return 0;
}
/// <inheritdoc/>
public void AddElement(IElement element, AttributeDefinition targetAttribute)
{
if (!this._attributes.TryGetValue(targetAttribute, out var attribute))
{
attribute = new ComposableAttribute(targetAttribute);
this._attributes.Add(targetAttribute, attribute);
this.OnAttributeAdded(attribute);
}
if (attribute is IComposableAttribute composableAttribute)
{
composableAttribute.AddElement(element);
}
else
{
throw new ArgumentException($"Attribute {targetAttribute} is not a composable attribute.");
}
}
/// <inheritdoc/>
public void RemoveElement(IElement element, AttributeDefinition targetAttribute)
{
if (this._attributes.TryGetValue(targetAttribute, out var attribute))
{
if (attribute is IComposableAttribute composableAttribute)
{
composableAttribute.RemoveElement(element);
if (!composableAttribute.Elements.Any())
{
this._attributes.Remove(targetAttribute);
}
}
else
{
throw new ArgumentException($"Attribute {targetAttribute} is not a composable attribute.");
}
}
}
/// <inheritdoc/>
public override string ToString()
{
var stringBuilder = new StringBuilder();
stringBuilder.AppendLine("Stat Attributes:");
foreach (var statAttribute in this._attributes.Values.OfType<StatAttribute>())
{
stringBuilder.AppendLine($" {statAttribute.Definition}: {statAttribute.Value}");
}
stringBuilder.AppendLine("Others:");
foreach (var attribute in this._attributes.Values.OfType<IComposableAttribute>())
{
stringBuilder.AppendLine($" {attribute.Definition}: {attribute.Value}");
}
return stringBuilder.ToString();
}
/// <inheritdoc />
public IEnumerator<IAttribute> GetEnumerator()
{
return this._attributes.Values.GetEnumerator();
}
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
/// <summary>
/// Gets or creates the element with the specified attribute.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <returns>The element of the attribute.</returns>
public IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition)
{
var element = this.GetAttribute(attributeDefinition);
if (element is null)
{
var composableAttribute = new ComposableAttribute(attributeDefinition);
element = composableAttribute;
this._attributes.Add(attributeDefinition, composableAttribute);
this.OnAttributeAdded(composableAttribute);
}
return element;
}
/// <summary>
/// Called when an attribute was added to the system after the initial construction.
/// </summary>
/// <param name="attribute">The attribute.</param>
protected virtual void OnAttributeAdded(IAttribute attribute)
{
// can be overwritten.
}
/// <summary>
/// Called when an attribute was removed from the system.
/// </summary>
/// <param name="attribute">The attribute.</param>
protected virtual void OnAttributeRemoved(IAttribute attribute)
{
// can be overwritten.
}
/// <summary>
/// Adds the attribute relationship.
/// </summary>
/// <param name="combination">The combination.</param>
private void AddAttributeRelationship(AttributeRelationship combination)
{
this.AddAttributeRelationship(combination, this, combination.AggregateType);
}
private IElement? GetAttribute(AttributeDefinition? attributeDefinition)
{
if (attributeDefinition is null)
{
return null;
}
if (this._attributes.TryGetValue(attributeDefinition, out var attribute))
{
return attribute;
}
return null;
}
}

View File

@@ -0,0 +1,54 @@
// <copyright file="BaseAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The base class for an attribute.
/// </summary>
public abstract class BaseAttribute : IAttribute
{
private AttributeDefinition _definition = null!;
/// <summary>
/// Initializes a new instance of the <see cref="BaseAttribute"/> class.
/// </summary>
/// <param name="definition">The definition.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
protected BaseAttribute(AttributeDefinition definition, AggregateType aggregateType)
{
this.Definition = definition;
this.AggregateType = aggregateType;
}
/// <inheritdoc/>
public event EventHandler? ValueChanged;
/// <inheritdoc/>
public virtual AttributeDefinition Definition
{
get => this._definition;
protected set => this._definition = value;
}
/// <inheritdoc/>
public abstract float Value { get; }
/// <inheritdoc/>
public AggregateType AggregateType { get; }
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Definition?.Designation}: {this.Value}";
}
/// <summary>
/// Raises the value changed event.
/// </summary>
protected void RaiseValueChanged()
{
this.ValueChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,33 @@
// <copyright file="BaseStatAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute which represents an increasable stat attribute (e.g. by level-up points).
/// </summary>
/// <remarks>
/// Intermediate class, needed because we want to add a setter.
/// We do just override the getter here and have to introduce a new Value get/set-property on a derived type.
/// </remarks>
public abstract class BaseStatAttribute : BaseAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="BaseStatAttribute"/> class.
/// </summary>
/// <param name="definition">The definition.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
protected BaseStatAttribute(AttributeDefinition definition, AggregateType aggregateType)
: base(definition, aggregateType)
{
}
/// <inheritdoc/>
public override float Value => this.ValueGetter;
/// <summary>
/// Gets the value.
/// </summary>
protected abstract float ValueGetter { get; }
}

View File

@@ -0,0 +1,48 @@
// <copyright file="CombinedElement.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An element which combines two elements into one.
/// </summary>
public class CombinedElement : IElement
{
private readonly IElement _element1;
private readonly IElement _element2;
/// <summary>
/// Initializes a new instance of the <see cref="CombinedElement"/> class.
/// </summary>
/// <param name="element1">The first element.</param>
/// <param name="element2">The second element.</param>
/// <exception cref="ArgumentException">The aggregate type of both elements need to match.</exception>
public CombinedElement(IElement element1, IElement element2)
{
if (element1.AggregateType != element2.AggregateType)
{
throw new ArgumentException($"The aggregate type of both elements need to match. Element1: {element1.AggregateType}, Element2: {element2.AggregateType}");
}
this._element1 = element1;
this._element2 = element2;
this._element1.ValueChanged += (_, _) => this.ValueChanged?.Invoke(this, EventArgs.Empty);
this._element2.ValueChanged += (_, _) => this.ValueChanged?.Invoke(this, EventArgs.Empty);
}
/// <inheritdoc />
public event EventHandler? ValueChanged;
/// <inheritdoc />
public float Value => this._element1.Value + this._element2.Value;
/// <inheritdoc />
public AggregateType AggregateType => this._element1.AggregateType;
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Value} ({this.AggregateType})";
}
}

View File

@@ -0,0 +1,97 @@
// <copyright file="ComposableAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute which is a composition of elements.
/// </summary>
public class ComposableAttribute : BaseAttribute, IComposableAttribute
{
private readonly IList<IElement> _elementList;
private float? _maximumValue;
private float? _cachedValue;
/// <summary>
/// Initializes a new instance of the <see cref="ComposableAttribute" /> class.
/// </summary>
/// <param name="definition">The definition.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
/// <param name="maximumValue">The inner maximum value.</param>
public ComposableAttribute(AttributeDefinition definition, AggregateType aggregateType = AggregateType.AddRaw, float? maximumValue = null)
: base(definition, aggregateType)
{
this._elementList = new List<IElement>();
this._maximumValue = maximumValue;
}
/// <inheritdoc/>
public IEnumerable<IElement> Elements => this._elementList;
/// <inheritdoc/>
public override float Value => this._cachedValue ?? this.GetAndCacheValue();
/// <inheritdoc/>
public IComposableAttribute AddElement(IElement element)
{
this._elementList.Add(element);
element.ValueChanged += this.ElementChanged;
this.ElementChanged(element, EventArgs.Empty);
return this;
}
/// <inheritdoc/>
public void RemoveElement(IElement element)
{
if (this._elementList.Remove(element))
{
element.ValueChanged -= this.ElementChanged;
this.ElementChanged(element, EventArgs.Empty);
}
}
private float GetAndCacheValue()
{
if (this._elementList.Count == 0)
{
this._cachedValue = 0;
return 0;
}
var rawValues = this.Elements.Where(e => e.AggregateType == AggregateType.AddRaw).Sum(e => e.Value);
var multiValues = this.Elements.Where(e => e.AggregateType == AggregateType.Multiplicate).Select(e => e.Value).Concat(Enumerable.Repeat(1.0F, 1)).Aggregate((a, b) => a * b);
var finalValues = this.Elements.Where(e => e.AggregateType == AggregateType.AddFinal).Sum(e => e.Value);
var maxValues = this.Elements.Where(e => e.AggregateType == AggregateType.Maximum).MaxBy(e => e.Value)?.Value ?? 0;
rawValues += maxValues;
if (this.Elements.All(e => e.AggregateType == AggregateType.Multiplicate))
{
rawValues = 1;
}
var newValue = (rawValues * multiValues) + finalValues;
if (this._maximumValue.HasValue)
{
newValue = Math.Min(this._maximumValue.Value, newValue);
}
if (this.Definition.MaximumValue.HasValue)
{
newValue = Math.Min(this.Definition.MaximumValue.Value, newValue);
}
this._cachedValue = newValue;
return this._cachedValue.Value;
}
private void ElementChanged(object? sender, EventArgs eventArgs)
{
this._cachedValue = null;
this.RaiseValueChanged();
}
}

View File

@@ -0,0 +1,67 @@
// <copyright file="ConstValueAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute with a constant value.
/// </summary>
public class ConstValueAttribute : IAttribute
{
private AttributeDefinition _definition = null!;
/// <summary>
/// Initializes a new instance of the <see cref="ConstValueAttribute" /> class.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="definition">The definition.</param>
public ConstValueAttribute(float value, AttributeDefinition definition)
{
this.Value = value;
this._definition = definition;
}
/// <summary>
/// Initializes a new instance of the <see cref="ConstValueAttribute"/> class.
/// </summary>
protected ConstValueAttribute()
{
}
/// <inheritdoc/>
/// <remarks>Empty implementation, because the value can't change.</remarks>
public event EventHandler? ValueChanged
{
#pragma warning disable S3237 //Empty implementation, because the value can't change.
add
{
// no action required
}
remove
{
// no action required
}
#pragma warning restore S3237
}
/// <inheritdoc/>
public virtual AttributeDefinition Definition
{
get => this._definition;
protected set => this._definition = value;
}
/// <inheritdoc/>
public float Value { get; protected set; }
/// <inheritdoc/>
public AggregateType AggregateType => AggregateType.AddRaw;
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Definition.Designation}: {this.Value}";
}
}

View File

@@ -0,0 +1,50 @@
// <copyright file="ConstantElement.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An element with a constant value.
/// </summary>
public class ConstantElement : IElement
{
/// <summary>
/// Initializes a new instance of the <see cref="ConstantElement" /> class.
/// </summary>
/// <param name="value">The constant value.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
public ConstantElement(float value, AggregateType aggregateType = AggregateType.AddRaw)
{
this.Value = value;
this.AggregateType = aggregateType;
}
/// <summary>
/// Never occurs, so the implementation is empty.
/// </summary>
public event EventHandler? ValueChanged
{
add
{
// do nothing, as the value never changes.
}
remove
{
// do nothing, as the value never changes.
}
}
/// <inheritdoc/>
public float Value { get; }
/// <inheritdoc/>
public AggregateType AggregateType { get; }
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Value} ({this.AggregateType})";
}
}

View File

@@ -0,0 +1,30 @@
// <copyright file="Extensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// Common extension methods.
/// </summary>
public static class Extensions
{
/// <summary>
/// Returns the input operator as string.
/// </summary>
/// <param name="inputOperator">The input operator.</param>
/// <returns>The textual representation of the input operator.</returns>
public static string AsString(this InputOperator inputOperator)
{
return inputOperator switch
{
InputOperator.Add => "+",
InputOperator.Multiply => "*",
InputOperator.Exponentiate => "^",
InputOperator.ExponentiateByAttribute => "^",
InputOperator.Maximum => "<max>",
InputOperator.Minimum => "<min>",
_ => string.Empty,
};
}
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The interface of an attribute.
/// </summary>
public interface IAttribute : IElement
{
/// <summary>
/// Gets the attribute definition.
/// </summary>
AttributeDefinition Definition { get; }
}

View File

@@ -0,0 +1,54 @@
// <copyright file="IAttributeSystem.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute system which holds all attributes of a game object.
/// </summary>
public interface IAttributeSystem
{
/// <summary>
/// Gets or sets the value with the specified attribute definition.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <returns>The value of the specified attribute.</returns>
float this[AttributeDefinition attributeDefinition] { get; set; }
/// <summary>
/// Gets the value of the attribute.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <returns>The value of the attribute.</returns>
float GetValueOfAttribute(AttributeDefinition attributeDefinition);
/// <summary>
/// Adds the element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="targetAttribute">The target attribute.</param>
void AddElement(IElement element, AttributeDefinition targetAttribute);
/// <summary>
/// Removes the element.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="targetAttribute">The target attribute.</param>
void RemoveElement(IElement element, AttributeDefinition targetAttribute);
/// <summary>
/// Adds the attribute relationship.
/// </summary>
/// <param name="relationship">The relationship.</param>
/// <param name="sourceAttributeHolder">The source attribute holder. May be the attribute system of another player.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
void AddAttributeRelationship(AttributeRelationship relationship, IAttributeSystem sourceAttributeHolder, AggregateType aggregateType);
/// <summary>
/// Gets or creates the element with the specified attribute.
/// </summary>
/// <param name="attributeDefinition">The attribute definition.</param>
/// <returns>The element of the attribute.</returns>
IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition);
}

View File

@@ -0,0 +1,35 @@
// <copyright file="IComposableAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The Attribute... Hint: every attribute could be wrapped into an element to chain them.
/// Example: We have a base strength attribute which could be wrapped into an element,
/// and this element could be added to a total strength attribute, which will
/// also contain the elements gotten from master tree or ancient items.
/// This way we could identify the base stats, which are needed for the character stats packet.
/// Another use could define an aggregate function (either addition or multiplication), and again
/// we could chain this calculations together (keeping commutative property).
/// </summary>
public interface IComposableAttribute : IAttribute
{
/// <summary>
/// Gets the elements, of which this attribute is calculated.
/// </summary>
IEnumerable<IElement> Elements { get; }
/// <summary>
/// Adds the element to the composition.
/// </summary>
/// <param name="element">The element.</param>
/// <returns>The composable attribute itself, to be able to chain adding.</returns>
IComposableAttribute AddElement(IElement element);
/// <summary>
/// Removes the element from the composition.
/// </summary>
/// <param name="element">The element.</param>
void RemoveElement(IElement element);
}

View File

@@ -0,0 +1,26 @@
// <copyright file="IElement.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The interface of an element.
/// </summary>
public interface IElement
{
/// <summary>
/// Occurs when the value has been changed.
/// </summary>
event EventHandler? ValueChanged;
/// <summary>
/// Gets the value.
/// </summary>
float Value { get; }
/// <summary>
/// Gets the type of the aggregate.
/// </summary>
AggregateType AggregateType { get; }
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\bin\Debug\MUnique.OpenMU.AttributeSystem.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\bin\Release\MUnique.OpenMU.AttributeSystem.xml</DocumentationFile>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
using System.Runtime.CompilerServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.AttributeSystem")]
[assembly: InternalsVisibleTo("MUnique.OpenMU.AttributeSystem.Tests")]

View File

@@ -0,0 +1,56 @@
# Attribute System
This project contains all what's required to create a so-called "Attribute System".
I'm sure it could be helpful also for other games, even other kind of games,
because it's pretty generic and not bound to MU Online or RPGs in general.
## What is it?
In OpenMU, every object which attacks and is attackable (interface IAttackable),
has an instance of an Attribute System. This Attribute System contains all attributes
of this entity.
## And how is it used?
For example a attribute system of a player can contain the following attributes:
* Strength
* Agility
* Vitality
* Health
* Level
* Physical Damage (min)
* Physical Damage (max)
Attributes can depend on each other and attributes values are calculated automatically.
For example we can define that Health is calculated by multiplying Vitality by
2 and adding the Level. Or that the minimum physical damage is Strength divided
by 6.
We can define such relationships by AttributeRelationship objects.
To make this work there need to be attributes which don't depend on other ones
and can be influenced by the player. These are called "StatAttributes".
StatAttributes are for example:
* Level (increasable by gaining experience)
* Base Strength (increasable by distributing free level up points)
Attribute values can get increased by equipping items. For example, there are
ancient items which increase Strength when they are equipped. Or more classical,
Swords which would increase the minimum and maximum physical damage attributes.
E.g. for Strength we usually have another attribute which is called "Total Strength",
which is basically the "Base Strength", but it can be a target attribute of an
AttributeRelationship. This "Total Strength" is then used for item requirements
or the further damage calculation. The "Base Strength" is the value which is
usually persisted to the database for a specific player.
In case of this server project, these relationships are all defined the
configuration for each character class. For some examples you can have a look
at the CharacterClassInitialization of the MUnique.OpenMU.Persistence.Initialization
project.
## Tests
This project also includes some unit tests for AttributeSystem, so check it out
if you want to find out how that works as a whole.

View File

@@ -0,0 +1,79 @@
// <copyright file="SimpleElement.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// A simple element with a variable value.
/// </summary>
public class SimpleElement : IElement
{
private float _value;
private AggregateType _aggregateType;
/// <summary>
/// Initializes a new instance of the <see cref="SimpleElement"/> class.
/// </summary>
public SimpleElement()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SimpleElement"/> class.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="aggregateType">Type of the aggregate.</param>
public SimpleElement(float value, AggregateType aggregateType)
{
this._value = value;
this._aggregateType = aggregateType;
}
/// <inheritdoc/>
public event EventHandler? ValueChanged;
/// <inheritdoc/>
public virtual float Value
{
get => this._value;
set
{
if (Math.Abs(this._value - value) > 0.00001f)
{
this._value = value;
this.RaiseValueChanged();
}
}
}
/// <inheritdoc/>
public AggregateType AggregateType
{
get => this._aggregateType;
set
{
if (this._aggregateType != value)
{
this._aggregateType = value;
this.RaiseValueChanged();
}
}
}
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Value} ({this.AggregateType})";
}
/// <summary>
/// Raises the value changed event.
/// </summary>
protected void RaiseValueChanged()
{
this.ValueChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,63 @@
// <copyright file="StatAttribute.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.AttributeSystem;
/// <summary>
/// An attribute which represents an increasable stat attribute (e.g. by level-up points).
/// </summary>
public class StatAttribute : BaseStatAttribute
{
private float _statValue;
/// <summary>
/// Initializes a new instance of the <see cref="StatAttribute"/> class.
/// </summary>
public StatAttribute()
: base(null!, AggregateType.AddRaw)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="StatAttribute"/> class.
/// </summary>
/// <param name="definition">The definition.</param>
/// <param name="baseValue">The base value.</param>
public StatAttribute(AttributeDefinition definition, float baseValue)
: base(definition, AggregateType.AddRaw)
{
this._statValue = baseValue;
}
/// <summary>
/// Gets or sets the value.
/// </summary>
/// <value>
/// The value.
/// </value>
public new virtual float Value
{
get
{
if (this.Definition?.MaximumValue is { } maxValue)
{
return Math.Min(maxValue, this._statValue);
}
return this._statValue;
}
set
{
if (Math.Abs(this._statValue - value) > 0.01f)
{
this._statValue = value;
this.RaiseValueChanged();
}
}
}
/// <inheritdoc/>
protected override float ValueGetter => this._statValue;
}

View File

@@ -0,0 +1,283 @@
// <copyright file="ChatClient.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using System.Buffers;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets;
using MUnique.OpenMU.Network.Packets.ChatServer;
using MUnique.OpenMU.Network.Xor;
/// <summary>
/// ChatClient implementation, uses socket connections.
/// </summary>
/// <remarks>
/// Messages are decrypted and encrypted again with the same XOR3 key - in theory we could optimize this (and
/// the conversion to a string) away. However, we'll leave it for easier debugging.
/// </remarks>
internal class ChatClient : IChatClient
{
private const int TokenOffset = 6;
private const int MessageOffset = 5;
private static readonly ISpanDecryptor TokenDecryptor = new Xor3Decryptor(TokenOffset);
/// <summary>
/// <see cref="ISpanDecryptor"/> for chat messages. The incoming chat messages are "encrypted" with the commonly known XOR-3 encryption for reasons we don't know ;).
/// </summary>
private static readonly ISpanDecryptor MessageDecryptor = new Xor3Decryptor(MessageOffset);
/// <summary>
/// <see cref="ISpanEncryptor"/> for chat messages. The outgoing chat messages are "encrypted" with the commonly known XOR-3 encryption for reasons we don't know ;).
/// </summary>
private static readonly ISpanEncryptor MessageEncryptor = new Xor3Encryptor(MessageOffset);
private readonly ChatRoomManager _manager;
private readonly ILogger<ChatClient> _logger;
private readonly byte[] _packetBuffer = new byte[0xFF];
private IConnection? _connection;
private ChatRoom? _room;
/// <summary>
/// Initializes a new instance of the <see cref="ChatClient" /> class.
/// </summary>
/// <param name="connection">The connection.</param>
/// <param name="manager">The manager.</param>
/// <param name="logger">The logger.</param>
public ChatClient(IConnection connection, ChatRoomManager manager, ILogger<ChatClient> logger)
{
this._manager = manager;
this._logger = logger;
this._connection = connection;
this._connection.PacketReceived += this.ReadPacketAsync;
this._connection.Disconnected += this.LogOffAsync;
this.LastActivity = DateTime.Now;
_ = this._connection.BeginReceiveAsync();
}
/// <summary>
/// Occurs when the client has been disconnected.
/// </summary>
public event EventHandler? Disconnected;
/// <inheritdoc/>
public byte Index
{
get;
set;
}
/// <inheritdoc />
public string? AuthenticationToken { get; private set; }
/// <inheritdoc/>
public string? Nickname { get; set; }
/// <inheritdoc/>
public DateTime LastActivity { get; private set; }
/// <inheritdoc/>
public async ValueTask SendMessageAsync(byte senderId, string message)
{
if (this._connection is not { } connection)
{
return;
}
int WritePacket()
{
var messageLength = (byte)Encoding.UTF8.GetByteCount(message);
var length = ChatMessageRef.GetRequiredSize(messageLength);
var packet = new ChatMessageRef(connection.Output.GetSpan(length)[..length]);
packet.SenderIndex = senderId;
packet.MessageLength = messageLength;
Encoding.UTF8.GetBytes(message, packet.Message);
MessageEncryptor.Encrypt(packet);
return length;
}
await this._connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask SendChatRoomClientListAsync(IReadOnlyCollection<IChatClient> clients)
{
if (this._connection is not { } connection)
{
return;
}
int WritePacket()
{
var length = ChatRoomClientsRef.GetRequiredSize(clients.Count);
var packet = new ChatRoomClientsRef(connection.Output.GetSpan(length)[..length]);
packet.ClientCount = (byte)clients.Count;
int i = 0;
foreach (var client in clients)
{
var clientBlock = packet[i];
clientBlock.Index = client.Index;
clientBlock.Name = client.Nickname ?? string.Empty;
i++;
}
return length;
}
await this._connection.SendAsync(WritePacket).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask SendChatRoomClientUpdateAsync(byte updatedClientId, string updatedClientName, ChatRoomClientUpdateType updateType)
{
if (this._connection is null || !this._connection.Connected)
{
return;
}
try
{
if (updateType == ChatRoomClientUpdateType.Joined)
{
await this._connection.SendChatRoomClientJoinedAsync(updatedClientId, updatedClientName).ConfigureAwait(false);
}
else
{
await this._connection.SendChatRoomClientLeftAsync(updatedClientId, updatedClientName).ConfigureAwait(false);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error sending room update");
}
}
/// <inheritdoc/>
public async ValueTask LogOffAsync()
{
if (this._connection is null)
{
this._logger.LogDebug("Client {Nickname} is already disconnected.", this.Nickname);
return;
}
this._logger.LogDebug("Client {Connection} is going to be disconnected.", this._connection);
if (this._room != null)
{
await this._room.LeaveAsync(this).ConfigureAwait(false);
this._room = null;
}
if (this._connection is { } connection)
{
await connection.DisconnectAsync().ConfigureAwait(false);
}
this._connection = null;
this.Disconnected?.Invoke(this, EventArgs.Empty);
this.Disconnected = null;
}
/// <summary>
/// Returns a <see cref="string" /> that represents this instance.
/// </summary>
/// <returns>
/// A <see cref="string" /> that represents this instance.
/// </returns>
public override string ToString()
{
return $"Connection:{this._connection}, Client name:{this.Nickname}, Room-ID:{this._room?.RoomId}, Index: {this.Index}";
}
private async ValueTask ReadPacketAsync(ReadOnlySequence<byte> sequence)
{
if (sequence.Length < 3)
{
return;
}
sequence.CopyTo(this._packetBuffer);
var packet = this._packetBuffer.AsMemory(0, (int)sequence.Length);
if (this._packetBuffer[0] != Authenticate.HeaderType)
{
return;
}
this.LastActivity = DateTime.Now;
switch (this._packetBuffer[2])
{
case 0:
await this.AuthenticateAsync(packet).ConfigureAwait(false);
break;
case 1:
case 2:
case 3:
// We did never capture such packets, but they don't seem to be wrong (next is 4), so do nothing.
break;
case 4:
if (this._room != null && this.CheckMessage(packet))
{
MessageDecryptor.Decrypt(packet.Span);
var message = packet.Span.ExtractString(5, int.MaxValue, Encoding.UTF8);
if (this._logger.IsEnabled(LogLevel.Debug))
{
this._logger.LogDebug("Message received from {Index}: \"{message}\"", this.Index, message);
}
await this._room.SendMessageAsync(this.Index, message).ConfigureAwait(false);
}
break;
case 5:
// This is something like a keep-connection-alive packet.
// Last activity is always set, so we have to do nothing here.
this._logger.LogDebug("Keep-alive received");
break;
case var value:
this._logger.LogError("Received unknown packet of type {PacketType}: {PacketSpan}", value, packet.Span.AsString());
await this.LogOffAsync().ConfigureAwait(false);
break;
}
}
private bool CheckMessage(Memory<byte> packet)
{
return packet.Length > 4 && (packet.Span[4] + 5) <= packet.Length;
}
private async ValueTask AuthenticateAsync(Memory<byte> packet)
{
var roomId = NumberConversionExtensions.MakeWord(packet.Span[4], packet.Span[5]);
var requestedRoom = this._manager.GetChatRoom(roomId);
if (requestedRoom is null)
{
this._logger.LogError("Requested room {RoomId} has not been registered before.", roomId);
await this.LogOffAsync().ConfigureAwait(false);
return;
}
TokenDecryptor.Decrypt(packet.Span);
var tokenAsString = packet.Span.ExtractString(TokenOffset, 10, Encoding.UTF8);
if (!uint.TryParse(tokenAsString, out uint _))
{
this._logger.LogError("Token '{TokenAsString}' is not a parseable integer.", tokenAsString);
await this.LogOffAsync().ConfigureAwait(false);
return;
}
this.AuthenticationToken = tokenAsString;
if (await requestedRoom.TryJoinAsync(this).ConfigureAwait(false))
{
this._room = requestedRoom;
}
else
{
await this.LogOffAsync().ConfigureAwait(false);
}
}
}

285
src/ChatServer/ChatRoom.cs Normal file
View File

@@ -0,0 +1,285 @@
// <copyright file="ChatRoom.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// This class represents a Chat Room.
/// </summary>
internal sealed class ChatRoom : IDisposable
{
private readonly ILogger<ChatRoom> _logger;
/// <summary>
/// Nicknames of the registered Clients.
/// </summary>
private readonly IList<ChatServerAuthenticationInfo> _registeredClients;
/// <summary>
/// Gets the <see cref="IChatClient"/>s which are currently connected to the ChatRoom.
/// </summary>
private readonly List<IChatClient> _connectedClients;
private ReaderWriterLockSlim? _lockSlim = new();
private int _lastUsedClientIndex = -1;
private bool _isClosing;
/// <summary>
/// Initializes a new instance of the <see cref="ChatRoom" /> class.
/// </summary>
/// <param name="roomId">The room identifier.</param>
/// <param name="logger">The logger.</param>
public ChatRoom(ushort roomId, ILogger<ChatRoom> logger)
{
this._logger = logger;
this._logger.LogDebug("Creating room {RoomId}", roomId);
this._connectedClients = new List<IChatClient>(2);
this._registeredClients = new List<ChatServerAuthenticationInfo>(2);
this.RoomId = roomId;
this.AuthenticationRequiredUntil = DateTime.Now.AddSeconds(10);
}
/// <summary>
/// Gets the currently connected clients.
/// </summary>
public IReadOnlyCollection<IChatClient> ConnectedClients => this._connectedClients;
/// <summary>
/// Gets the id of the Chat Room.
/// </summary>
public ushort RoomId { get; }
/// <summary>
/// Gets a datetime indicating until a authentication is required.
/// </summary>
public DateTime AuthenticationRequiredUntil { get; private set; }
/// <summary>
/// Gets or sets the room closed event handler.
/// </summary>
public EventHandler<ChatRoomClosedEventArgs>? RoomClosed { get; set; }
/// <summary>
/// Registers a chat client to the chatroom. this is only called
/// by the game server which will send id to the participants
/// over the games connection.
/// </summary>
/// <param name="authenticationInfo">Authentication information of the participant.</param>
public void RegisterClient(ChatServerAuthenticationInfo authenticationInfo)
{
if (this._isClosing)
{
throw new ObjectDisposedException("Chat room is already disposed.");
}
if (authenticationInfo.RoomId != this.RoomId)
{
throw new ArgumentException(
$"The RoomId of the authentication info ({authenticationInfo.RoomId}) does not match with this RoomId ({this.RoomId}).");
}
this.AuthenticationRequiredUntil = authenticationInfo.AuthenticationRequiredUntil;
this._registeredClients.Add(authenticationInfo);
}
/// <summary>
/// Gets the index of the next client.
/// </summary>
/// <returns>The index of the next client.</returns>
public byte GetNextClientIndex()
{
var clientIndex = Interlocked.Increment(ref this._lastUsedClientIndex);
return (byte)clientIndex;
}
/// <summary>
/// Closes this chat room by disconnecting all clients.
/// </summary>
public void Close()
{
this.Dispose();
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", MessageId = "lockSlim", Justification = "Null-conditional confuses the code analysis.")]
public void Dispose()
{
var localLockSlim = this._lockSlim;
if (this._isClosing || localLockSlim is null)
{
return;
}
this._isClosing = true;
this._lockSlim = null;
this._logger.LogDebug("Disposing room {RoomId}...", this.RoomId);
this._registeredClients.Clear();
localLockSlim.EnterWriteLock();
try
{
Task.Run(async () =>
{
foreach (var connectedClient in this._connectedClients)
{
await connectedClient.LogOffAsync().ConfigureAwait(false);
}
}).WaitAndUnwrapException();
this._connectedClients.Clear();
this.RoomClosed?.Invoke(this, new ChatRoomClosedEventArgs(this));
this.RoomClosed = null;
}
finally
{
localLockSlim.ExitWriteLock();
}
localLockSlim.Dispose();
this._logger.LogDebug("Room {RoomId} disposed.", this.RoomId);
}
/// <summary>
/// The specified client will join the chatroom, if its registered. The Nickname is set to the clients object.
/// </summary>
/// <param name="chatClient">The chat client.</param>
/// <returns>True, if the <paramref name="chatClient"/> provides the correct registered id with it's token.</returns>
internal async ValueTask<bool> TryJoinAsync(IChatClient chatClient)
{
if (chatClient is null)
{
throw new ArgumentNullException(nameof(chatClient));
}
if (this._isClosing)
{
throw new ObjectDisposedException("Chat room is already disposed.");
}
this._logger.LogDebug("Client {ChatClientIndex} is trying to join the room {RoomId} with token '{AuthenticationToken}'", chatClient.Index, this.RoomId, chatClient.AuthenticationToken);
this._lockSlim?.EnterWriteLock();
try
{
var authenticationInformation = this._registeredClients.FirstOrDefault(info => string.Equals(info.AuthenticationToken, chatClient.AuthenticationToken));
if (authenticationInformation != null)
{
if (authenticationInformation.AuthenticationRequiredUntil < DateTime.Now)
{
this._logger.LogInformation(
"Client {ChatClientIndex} has tried to join the room {RoomId} with token '{AuthenticationToken}', but was too late. It was valid until {AuthenticationRequiredUntil}.",
chatClient.Index,
this.RoomId,
chatClient.AuthenticationToken,
authenticationInformation.AuthenticationRequiredUntil);
}
else
{
chatClient.Nickname = authenticationInformation.ClientName;
chatClient.Index = authenticationInformation.Index;
this._registeredClients.Remove(authenticationInformation);
await this.SendChatRoomClientUpdateAsync(chatClient, ChatRoomClientUpdateType.Joined).ConfigureAwait(false);
this._connectedClients.Add(chatClient);
await chatClient.SendChatRoomClientListAsync(this._connectedClients).ConfigureAwait(false);
return true;
}
}
else
{
this._logger.LogInformation("Client {ChatClientIndex} has tried to join the room {RoomId} with token '{AuthenticationToken}', but was not registered.", chatClient.Index, this.RoomId, chatClient.AuthenticationToken);
}
}
finally
{
this._lockSlim?.ExitWriteLock();
}
return false;
}
/// <summary>
/// The specified client will leave the chatroom.
/// If the chatroom is empty then, it will be removed from the manager.
/// </summary>
/// <param name="chatClient">The chat client.</param>
internal async ValueTask LeaveAsync(IChatClient chatClient)
{
if (this._isClosing)
{
return;
}
this._logger.LogDebug($"Chat client ({chatClient}) is leaving.");
this._lockSlim?.EnterWriteLock();
try
{
this._connectedClients.Remove(chatClient);
}
finally
{
this._lockSlim?.ExitWriteLock();
}
bool roomIsEmpty;
this._lockSlim?.EnterReadLock();
try
{
roomIsEmpty = this._connectedClients.Count < 1;
if (!roomIsEmpty)
{
await this.SendChatRoomClientUpdateAsync(chatClient, ChatRoomClientUpdateType.Left).ConfigureAwait(false);
}
}
finally
{
this._lockSlim?.ExitReadLock();
}
if (roomIsEmpty)
{
this.Close();
}
}
/// <summary>
/// Sends a Message to all chat clients.
/// </summary>
/// <param name="senderId">The sender identifier.</param>
/// <param name="message">The message.</param>
internal async ValueTask SendMessageAsync(byte senderId, string message)
{
if (this._isClosing)
{
return;
}
this._lockSlim?.EnterReadLock();
try
{
foreach (var connectedClient in this._connectedClients)
{
await connectedClient.SendMessageAsync(senderId, message).ConfigureAwait(false);
}
}
finally
{
this._lockSlim?.ExitReadLock();
}
}
private async ValueTask SendChatRoomClientUpdateAsync(IChatClient updatedClient, ChatRoomClientUpdateType updateType)
{
foreach (var client in this._connectedClients)
{
await client.SendChatRoomClientUpdateAsync(updatedClient.Index, updatedClient.Nickname ?? string.Empty, updateType).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="ChatRoomClosedEventArgs.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
/// <summary>
/// Event arguments which contains the chat room which has been closed.
/// </summary>
/// <seealso cref="System.EventArgs" />
internal class ChatRoomClosedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ChatRoomClosedEventArgs"/> class.
/// </summary>
/// <param name="room">The chat room.</param>
public ChatRoomClosedEventArgs(ChatRoom room)
{
this.ChatRoom = room;
}
/// <summary>
/// Gets the chat room which has been closed.
/// </summary>
public ChatRoom ChatRoom { get; }
}

View File

@@ -0,0 +1,78 @@
// <copyright file="ChatRoomManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
/// <summary>
/// The Chat Room Manager manages the creation and destruction of chat rooms.
/// </summary>
internal class ChatRoomManager
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// All currently used chat rooms.
/// </summary>
private readonly IDictionary<ushort, ChatRoom> _rooms = new ConcurrentDictionary<ushort, ChatRoom>();
private readonly ConcurrentBag<ushort> _freeRoomIds = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChatRoomManager" /> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public ChatRoomManager(ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
for (ushort i = 0; i < ushort.MaxValue; ++i)
{
this._freeRoomIds.Add(i);
}
}
/// <summary>
/// Gets the opened rooms.
/// </summary>
public ICollection<ChatRoom> OpenedRooms => this._rooms.Values;
/// <summary>
/// Creates a new ChatRoom and returns its Room-ID.
/// </summary>
/// <returns>The Room-ID of the new room. Returns ushort.MaxValue, if there is no free chat room available.</returns>
public ushort CreateChatRoom()
{
if (!this._freeRoomIds.TryTake(out ushort roomId))
{
throw new InvalidOperationException("There is no free room id, so the chat room couldn't be created.");
}
var room = new ChatRoom(roomId, this._loggerFactory.CreateLogger<ChatRoom>());
room.RoomClosed += this.OnChatRoomClosed;
this._rooms.Add(roomId, room);
return roomId;
}
/// <summary>
/// Returns the chat room with the corresponding Room-ID.
/// Returns null, if ChatRoom wasn't found.
/// </summary>
/// <param name="roomId">Room-ID.</param>
/// <returns>ChatRoom or null.</returns>
internal ChatRoom? GetChatRoom(ushort roomId)
{
this._rooms.TryGetValue(roomId, out var room);
return room;
}
private void OnChatRoomClosed(object? sender, ChatRoomClosedEventArgs eventArgs)
{
var room = eventArgs.ChatRoom;
this._rooms.Remove(room.RoomId);
this._freeRoomIds.Add(room.RoomId);
room.Dispose();
}
}

View File

@@ -0,0 +1,361 @@
// <copyright file="ChatServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using System.ComponentModel;
using System.Net;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Threading;
using System.Timers;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.PlugIns;
using Timer = System.Timers.Timer;
/// <summary>
/// Chat Server Listener that accepts incoming connections.
/// </summary>
public sealed class ChatServer : IChatServer, IDisposable
{
private readonly ChatRoomManager _manager;
private readonly ILogger<ChatServer> _logger;
private readonly IIpAddressResolver _addressResolver;
private readonly ILoggerFactory _loggerFactory;
private readonly PlugInManager _plugInManager;
private readonly RandomNumberGenerator _randomNumberGenerator;
private readonly IList<IChatClient> _connectedClients = new List<IChatClient>();
private readonly IList<ChatServerListener> _listeners = new List<ChatServerListener>();
private Timer? _clientCleanupTimer;
private Timer? _roomCleanupTimer;
private ChatServerSettings? _settings;
private bool _isDisposed;
private ServerState _serverState;
/// <summary>
/// Initializes a new instance of the <see cref="ChatServer" /> class.
/// </summary>
/// <param name="addressResolver">The address resolver which returns the address on which the listener will be bound to.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="plugInManager">The plug in manager.</param>
public ChatServer(IIpAddressResolver addressResolver, ILoggerFactory loggerFactory, PlugInManager plugInManager)
{
this._addressResolver = addressResolver;
this._loggerFactory = loggerFactory;
this._plugInManager = plugInManager;
this._logger = loggerFactory.CreateLogger<ChatServer>();
this._manager = new ChatRoomManager(loggerFactory);
this._randomNumberGenerator = RandomNumberGenerator.Create();
}
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc/>
public string Description => this._settings?.Description ?? string.Empty;
/// <inheritdoc/>
public int Id => this.Settings?.ServerId ?? SpecialServerIds.ChatServer;
/// <inheritdoc />
public Guid ConfigurationId => this._settings?.Id ?? Guid.Empty;
/// <inheritdoc />
public ServerType Type => ServerType.ChatServer;
/// <inheritdoc/>
public ServerState ServerState
{
get => this._serverState;
private set
{
if (value != this._serverState)
{
this._serverState = value;
this.RaisePropertyChanged();
}
}
}
/// <inheritdoc/>
public int MaximumConnections => this.Settings.MaximumConnections;
/// <inheritdoc/>
public int CurrentConnections => this._connectedClients.Count;
private ChatServerSettings Settings => this._settings ?? throw new InvalidOperationException("The server was not initialized before");
/// <inheritdoc/>
public async ValueTask<ChatServerAuthenticationInfo?> RegisterClientAsync(ushort roomId, string clientName)
{
var room = this._manager.GetChatRoom(roomId);
if (room is null)
{
var errorMessage = $"RegisterClient: Could not find chat room with id {roomId} for '{clientName}'.";
this._logger.LogError(errorMessage);
throw new ArgumentException(errorMessage, nameof(roomId));
}
var ipAddress = await this._addressResolver.ResolveIPv4Async().ConfigureAwait(false);
var index = room.GetNextClientIndex();
var authenticationInfo = new ChatServerAuthenticationInfo(index, roomId, clientName, ipAddress.ToString(), this.GetRandomAuthenticationToken(index));
room.RegisterClient(authenticationInfo);
return authenticationInfo;
}
/// <inheritdoc/>
public ValueTask<ushort> CreateChatRoomAsync()
{
return ValueTask.FromResult(this._manager.CreateChatRoom());
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
await this.StartAsync().ConfigureAwait(false);
}
/// <summary>
/// Starts the listener of this chat server instance.
/// </summary>
public async ValueTask StartAsync()
{
if (this.ServerState != ServerState.Stopped)
{
return;
}
this._logger.LogInformation("Begin starting");
var oldState = this.ServerState;
this.ServerState = OpenMU.Interfaces.ServerState.Starting;
try
{
this.CreateListeners();
foreach (var listener in this._listeners)
{
listener.Start();
}
this.CreateCleanupTimers();
this.ServerState = OpenMU.Interfaces.ServerState.Started;
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error while starting");
this.ServerState = oldState;
}
this._logger.LogInformation("Finished starting");
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
await this.ShutdownAsync().ConfigureAwait(false);
}
/// <summary>
/// Initializes the server with the specified settings.
/// </summary>
/// <param name="settings">The settings.</param>
/// <exception cref="System.InvalidOperationException">Can only initialize when server is stopped.</exception>
public void Initialize(ChatServerSettings settings)
{
if (this.ServerState != ServerState.Stopped)
{
throw new InvalidOperationException("Can only initialize when server is stopped.");
}
this._settings = settings;
}
/// <inheritdoc/>
public async ValueTask ShutdownAsync()
{
if (this.ServerState != ServerState.Started)
{
return;
}
this._logger.LogInformation("Begin shutdown");
this.ServerState = OpenMU.Interfaces.ServerState.Stopping;
this.RemoveCleanupTimers();
foreach (var listener in this._listeners)
{
listener.Stop();
}
this._listeners.Clear();
this._logger.LogDebug("Disconnecting all clients");
var clients = this._connectedClients.ToList();
foreach (var client in clients)
{
try
{
await client.LogOffAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error logging client off.");
}
}
this.ServerState = OpenMU.Interfaces.ServerState.Stopped;
this._logger.LogInformation("Finished shutdown");
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
public void Dispose()
{
if (!this._isDisposed)
{
this._isDisposed = true;
this._randomNumberGenerator.Dispose();
this._clientCleanupTimer?.Dispose();
this._roomCleanupTimer?.Dispose();
}
}
private void CreateCleanupTimers()
{
this._clientCleanupTimer = new Timer(this.Settings.ClientCleanUpInterval.TotalMilliseconds);
this._clientCleanupTimer.Elapsed += this.ClientCleanupInactiveClients;
this._clientCleanupTimer.Start();
this._roomCleanupTimer = new Timer(this.Settings.RoomCleanUpInterval.TotalMilliseconds);
this._roomCleanupTimer.Elapsed += this.ClientCleanupUnusedRooms;
this._roomCleanupTimer.Start();
}
private void RemoveCleanupTimers()
{
this._clientCleanupTimer?.Stop();
this._clientCleanupTimer?.Dispose();
this._clientCleanupTimer = null;
this._roomCleanupTimer?.Stop();
this._roomCleanupTimer?.Dispose();
this._roomCleanupTimer = null;
}
private void CreateListeners()
{
foreach (var endpoint in this.Settings.Endpoints)
{
var listener = new ChatServerListener(endpoint, this._plugInManager, this._loggerFactory);
listener.ClientAccepted += this.ChatClientAcceptedAsync;
listener.ClientAccepting += this.ChatClientAcceptingAsync;
this._listeners.Add(listener);
}
}
/// <summary>
/// Gets a random authentication token.
/// </summary>
/// <param name="clientIndex">Index of the client.</param>
/// <returns>The random authentication token as a string.</returns>
/// <remarks>
/// This is the original way of generating the token - not especially secure, but to keep it simple, I leave it that way.
/// </remarks>
private string GetRandomAuthenticationToken(byte clientIndex)
{
var authenticationToken = new byte[] { clientIndex, 0, 0, 0 };
this._randomNumberGenerator.GetBytes(authenticationToken, 2, 2);
var tokenAsString = authenticationToken.MakeDwordBigEndian(0).ToString();
return tokenAsString;
}
private async ValueTask ChatClientAcceptingAsync(CancelEventArgs e)
{
if (this.Settings.MaximumConnections == int.MaxValue)
{
return;
}
e.Cancel = this.CurrentConnections >= this.Settings.MaximumConnections;
}
private async ValueTask ChatClientAcceptedAsync(ClientAcceptedEventArgs e)
{
var chatClient = new ChatClient(e.AcceptedConnection, this._manager, this._loggerFactory.CreateLogger<ChatClient>());
this._connectedClients.Add(chatClient);
this.RaisePropertyChanged(nameof(this.CurrentConnections));
chatClient.Disconnected += this.ChatClientDisconnected;
}
private void ChatClientDisconnected(object? sender, EventArgs e)
{
if (sender is IChatClient client)
{
this._connectedClients.Remove(client);
}
this.RaisePropertyChanged(nameof(this.CurrentConnections));
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void ClientCleanupInactiveClients(object? sender, ElapsedEventArgs e)
{
try
{
var bottomDateTimeMargin = DateTime.Now.Subtract(this.Settings.ClientTimeout);
for (int i = this._connectedClients.Count - 1; i >= 0; i--)
{
var client = this._connectedClients[i];
if (client.LastActivity >= bottomDateTimeMargin)
{
continue;
}
this._logger.LogDebug(
"Disconnecting client {Client}, because of activity timeout. LastActivity: {ClientLastActivity}", client, client.LastActivity);
await client.LogOffAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error during checking for inactive clients");
}
}
private void ClientCleanupUnusedRooms(object? sender, ElapsedEventArgs e)
{
try
{
var rooms = this._manager.OpenedRooms.Where(room => room.AuthenticationRequiredUntil < DateTime.Now && room.ConnectedClients.Count < 2).ToList();
foreach (var room in rooms)
{
this._logger.LogInformation($"Cleaning up room {room.RoomId}");
room.Close();
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error during cleanup of unused rooms");
}
}
/// <summary>
/// Called when a property changed.
/// </summary>
/// <param name="propertyName">Name of the property.</param>
private void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="ChatServerEndpoint.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using MUnique.OpenMU.Network.PlugIns;
/// <summary>
/// A client-version-specific endpoint for a chat server.
/// </summary>
public class ChatServerEndpoint
{
/// <summary>
/// Gets or sets the tcp network port under which the server is listening for new clients.
/// </summary>
public int NetworkPort { get; set; }
/// <summary>
/// Gets or sets the client version for which the endpoint is meant for.
/// </summary>
public ClientVersion ClientVersion { get; set; }
}

View File

@@ -0,0 +1,72 @@
// <copyright file="ChatServerListener.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using System.ComponentModel;
using System.IO.Pipelines;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A listener which listens to the specified endpoint and provides the initialized <see cref="IConnection"/> by the event <see cref="ClientAccepted"/>.
/// </summary>
public class ChatServerListener
{
private readonly ChatServerEndpoint _endpoint;
private readonly PlugInManager _plugInManager;
private readonly ILoggerFactory _loggerFactory;
private Listener? _chatClientListener;
/// <summary>
/// Initializes a new instance of the <see cref="ChatServerListener" /> class.
/// </summary>
/// <param name="endpoint">The endpoint.</param>
/// <param name="plugInManager">The plug in manager.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ChatServerListener(ChatServerEndpoint endpoint, PlugInManager plugInManager, ILoggerFactory loggerFactory)
{
this._endpoint = endpoint;
this._plugInManager = plugInManager;
this._loggerFactory = loggerFactory;
}
/// <summary>
/// Occurs when a new client was accepted.
/// </summary>
public event AsyncEventHandler<ClientAcceptedEventArgs>? ClientAccepted;
/// <summary>
/// Occurs when a client has been accepted by the tcp listener, but before a <see cref="Connection"/> is created.
/// </summary>
public event AsyncEventHandler<CancelEventArgs>? ClientAccepting;
/// <summary>
/// Starts the tcp listener of this instance.
/// </summary>
public void Start()
{
this._chatClientListener = new Listener(this._endpoint.NetworkPort, this.CreateDecryptor, _ => null, this._loggerFactory);
this._chatClientListener.ClientAccepted += async args => await this.ClientAccepted.SafeInvokeAsync(args).ConfigureAwait(false);
this._chatClientListener.ClientAccepting += async args => await this.ClientAccepting.SafeInvokeAsync(args).ConfigureAwait(false);
this._chatClientListener.Start();
}
/// <summary>
/// Stops this instance.
/// </summary>
public void Stop()
{
this._chatClientListener?.Stop();
}
private IPipelinedDecryptor? CreateDecryptor(PipeReader pipeReader)
{
var encryptionFactoryPlugIn = this._plugInManager.GetStrategy<ClientVersion, INetworkEncryptionFactoryPlugIn>(this._endpoint.ClientVersion)
?? this._plugInManager.GetStrategy<ClientVersion, INetworkEncryptionFactoryPlugIn>(default);
return encryptionFactoryPlugIn?.CreateDecryptor(pipeReader, DataDirection.ClientToServer);
}
}

View File

@@ -0,0 +1,57 @@
// <copyright file="ChatServerSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Settings for the <see cref="ChatServer"/>.
/// </summary>
public class ChatServerSettings
{
/// <summary>
/// Gets or sets the identifier for this configuration.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the server identifier.
/// </summary>
public int ServerId { get; set; } = SpecialServerIds.ChatServer;
/// <summary>
/// Gets or sets the description.
/// </summary>
public string Description { get; set; } = "Chat Server";
/// <summary>
/// Gets or sets the maximum connections.
/// </summary>
public int MaximumConnections { get; set; } = int.MaxValue;
/// <summary>
/// Gets or sets the client timeout. When a client did not send any data in this timespan, it's automatically disconnected.
/// </summary>
/// <value>
/// The client timeout.
/// </value>
public TimeSpan ClientTimeout { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Gets or sets the interval in which a client clean up takes place.
/// For all connected clients it's checked whether or not the <see cref="ClientTimeout"/> has been reached.
/// </summary>
public TimeSpan ClientCleanUpInterval { get; set; } = TimeSpan.FromMinutes(1);
/// <summary>
/// Gets or sets the interval in which empty chat rooms are cleaned up.
/// </summary>
public TimeSpan RoomCleanUpInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Gets the endpoints under which the chat server is available for specific game clients.
/// </summary>
public ICollection<ChatServerEndpoint> Endpoints { get; } = new List<ChatServerEndpoint>();
}

View File

@@ -0,0 +1,17 @@
##############################
# ChatServer Configuration #
##############################
# The following values are defaults and are even applied if this file or single configuration value-pairs are missing or are in the wrong format:
# ChatServerListenerPort=55980
# ExDbHost=127.0.0.1
# ExDbPort=55906
# Xor32Key=AB 11 CD FE 18 23 C5 A3 CA 33 C1 CC 66 67 21 F3 32 12 15 35 29 FF FE 1D 44 EF CD 41 26 3C 4E 4D
ChatServerListenerPort=55980
ExDbHost=127.0.0.1
ExDbPort=55906
Xor32Key=AB 11 CD FE 18 23 C5 A3 CA 33 C1 CC 66 67 21 F3 32 12 15 35 29 FF FE 1D 44 EF CD 41 26 3C 4E 4D

View File

@@ -0,0 +1,55 @@
// <copyright file="ConfigurableNetworkEncryptionPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
using System.ComponentModel.DataAnnotations;
using System.IO.Pipelines;
using System.Runtime.InteropServices;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.Network.Xor;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A configurable network encryption factory plugin which reads the Xor32 key from the ChatServer.cfg file. Only used by the ExDbConnector project.
/// </summary>
[PlugIn]
[Display(Name = "Configurable encryption plugin", Description = "A configurable network encryption factory plugin which reads the Xor32 key from the ChatServer.cfg file. Only used by the ExDbConnector project.")]
[Guid("890997B2-9334-4E9E-8C82-4492A831BCE3")]
public class ConfigurableNetworkEncryptionPlugIn : INetworkEncryptionFactoryPlugIn
{
private readonly byte[] _xor32Key;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurableNetworkEncryptionPlugIn"/> class.
/// </summary>
public ConfigurableNetworkEncryptionPlugIn()
{
var settings = new Settings("ChatServer.cfg");
this._xor32Key = settings.Xor32Key ?? new byte[32];
}
/// <summary>
/// Gets the version for which this plugin is available.
/// </summary>
public static ClientVersion Version { get; } = new(byte.MaxValue, byte.MaxValue, ClientLanguage.Invariant);
/// <inheritdoc />
public ClientVersion Key => Version;
/// <inheritdoc />
public IPipelinedDecryptor? CreateDecryptor(PipeReader source, DataDirection direction)
{
return new PipelinedXor32Decryptor(source, this._xor32Key);
}
/// <inheritdoc />
public IPipelinedEncryptor? CreateEncryptor(PipeWriter target, DataDirection direction)
{
// At least until season 6, there is no encryption from server to client.
// ex700 may require packet twister here.
return null;
}
}

View File

@@ -0,0 +1,275 @@
// <copyright file="ExDbClient.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
using System.Buffers;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets;
using Pipelines.Sockets.Unofficial;
using static System.Buffers.Binary.BinaryPrimitives;
/// <summary>
/// The connected exDB server. This class includes the communication implementation between chat server and exDB server.
/// It registers clients for the chat server and hands back their authentication details.
/// </summary>
public class ExDbClient
{
private readonly ILogger<ExDbClient> _logger;
private readonly string _host;
private readonly int _port;
private readonly IChatServer _chatServer;
private readonly ILoggerFactory _loggerFactory;
private readonly ushort _chatServerPort;
private readonly byte[] _packetBuffer = new byte[0xFF];
private IConnection? _connection;
/// <summary>
/// Initializes a new instance of the <see cref="ExDbClient" /> class.
/// </summary>
/// <param name="host">The host address of the exDB server.</param>
/// <param name="port">The host port of the exDB server.</param>
/// <param name="chatServer">The chat server.</param>
/// <param name="chatServerPort">The chat server port.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ExDbClient(string host, int port, IChatServer chatServer, int chatServerPort, ILoggerFactory loggerFactory)
{
this._host = host;
this._port = port;
this._chatServer = chatServer;
this._loggerFactory = loggerFactory;
this._chatServerPort = (ushort)chatServerPort;
this._logger = this._loggerFactory.CreateLogger<ExDbClient>();
_ = Task.Run(this.ConnectAsync);
}
/// <summary>
/// Disconnects the exDB server.
/// </summary>
public async ValueTask DisconnectAsync()
{
if (this._connection is { } connection)
{
await connection.DisconnectAsync().ConfigureAwait(false);
}
}
private async ValueTask ConnectAsync()
{
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
while (!socket.Connected)
{
try
{
await socket.ConnectAsync(this._host, this._port).ConfigureAwait(false);
}
catch
{
this._logger.LogWarning($"Connection to ExDB-Server ({this._host}:{this._port}) failed, trying again in 10 Seconds...");
await Task.Delay(10000).ConfigureAwait(false);
}
}
this._logger.LogInformation("Connection to ExDB-Server established");
this._connection = new Connection(SocketConnection.Create(socket), null, null, this._loggerFactory.CreateLogger<Connection>());
this._connection.PacketReceived += this.ExDbPacketReceivedAsync;
this._connection.Disconnected += this.ConnectAsync;
await this.SendHelloAsync().ConfigureAwait(false);
await this._connection!.BeginReceiveAsync().ConfigureAwait(false);
}
private async ValueTask SendHelloAsync()
{
// C1 3A 00 02 AC DA 43 68 61 74 53 65 72 76 65 72 00 ...
int Write()
{
var length = 0x3A;
var span = this._connection!.Output.GetSpan(length)[..length];
var packet = span;
packet[0] = 0xC1;
packet[1] = 0x3A;
packet[3] = 0x02;
packet[4] = this._chatServerPort.GetLowByte();
packet[5] = this._chatServerPort.GetHighByte();
packet.Slice(6).WriteString("ChatServer", Encoding.UTF8);
return length;
}
await this._connection!.SendAsync(Write).ConfigureAwait(false);
this._logger.LogInformation("Sent registration packet to ExDB-Server");
}
/// <summary>
/// Is called when a packet is received from the exDB-Server.
/// </summary>
/// <param name="sequence">The packet.</param>
private async ValueTask ExDbPacketReceivedAsync(ReadOnlySequence<byte> sequence)
{
try
{
sequence.CopyTo(this._packetBuffer);
var packet = this._packetBuffer.AsMemory(0, (int)sequence.Length);
var type = packet.Span[0];
if (type != 0xC1)
{
this._logger.LogWarning($"Unknown packet received from ExDB-Server, type: {type}");
return;
}
var code = packet.Span[2];
switch (code)
{
case 0xA0:
await this.ReadChatRoomCreationAsync(packet).ConfigureAwait(false);
break;
case 0xA1:
await this.ReadChatRoomInvitationAsync(packet).ConfigureAwait(false);
break;
default:
this._logger.LogWarning($"Unknown packet received from ExDB-Server, code: {code}");
break;
}
}
catch (Exception exception)
{
this._logger.LogError(exception, $"An error occurred while processing an incoming packet from ExDB: {this._packetBuffer.AsString()}");
}
}
/// <summary>
/// Reads the invitation to an existing chat room and registers the invited client.
/// </summary>
/// <param name="packet">The packet.</param>
/// <remarks>
/// Example: C1 15 A1 00 00 00 61 62 63 64 65 66 67 68 69 6F 20 01 00 01 57
/// Index 4 and 5 is the room id, the next 10 bytes is the client name, after that the player id, game server id and a "type".
/// The chat server answers this with the same packets as above(ticket 96862210):
/// C1 2C A0 01 00 00 61 62 63 64 65 66 67 68 69 6F CC CC CC CC CC CC CC CC CC CC 53 54 55 56 CC CC 02 00 C6 05 CC CC CC CC 57 CC CC CC.
/// </remarks>
private async ValueTask ReadChatRoomInvitationAsync(Memory<byte> packet)
{
ushort roomId = 0;
string clientName = string.Empty;
ushort clientPlayerId = 0;
ushort clientServerId = 0;
byte type = 0;
void Extract(Span<byte> packet)
{
roomId = NumberConversionExtensions.MakeWord(packet[4], packet[5]);
clientName = packet.ExtractString(6, 10, Encoding.UTF8);
clientPlayerId = packet.TryMakeWordBigEndian(16);
clientServerId = packet.TryMakeWordBigEndian(18);
type = packet.Length > 20 ? packet[20] : (byte)0x57;
}
Extract(packet.Span);
this._logger.LogDebug($"Received request to invite {clientName} to chat room {roomId}, Client-ID: {clientPlayerId}, Server-ID: {clientServerId}");
if (await this._chatServer.RegisterClientAsync(roomId, clientName).ConfigureAwait(false) is { } authentication)
{
await this.SendAuthenticationAsync(authentication, null, clientPlayerId, clientServerId, type).ConfigureAwait(false);
}
}
/// <summary>
/// Reads the chat room creation message, creates a new chat room and registers the clients.
/// </summary>
/// <param name="packet">The packet.</param>
/// <remarks>
/// For example, we get here the following packet in:
/// C1 20 A0 41 42 43 44 45 46 47 48 49 4A 50 51 52 53 54 55 56 57 58 59 00 E0 2E 01 00 E1 2E 01 00
/// This packet includes the header and both names of the creator and the invited chat partner (each 10 bytes long).
/// The server should then send the following data back to the exDB-Server:
/// s | rid ||-----client name-----------||---------other client name-||plid| |svid||---| |-ticket--| |--------???----------|
/// C1 2C A0 01 00 00 41 42 43 44 45 46 47 48 49 4A 50 51 52 53 54 55 56 57 58 59 00 00 00 00 CC CC 00 00 11 04 CC CC CC CC 00 CC CC CC
/// C1 2C A0 01 00 00 50 51 52 53 54 55 56 57 58 59 41 42 43 44 45 46 47 48 49 4A 00 00 00 00 CC CC 01 00 BB 05 CC CC CC CC 01 CC CC CC.
/// </remarks>
private async ValueTask ReadChatRoomCreationAsync(Memory<byte> packet)
{
string clientName = string.Empty;
string friendName = string.Empty;
ushort clientPlayerId = 0;
ushort clientServerId = 0;
ushort friendPlayerId = 0;
ushort friendServerId = 0;
void Extract(Span<byte> packet)
{
clientName = packet.ExtractString(3, 10, Encoding.UTF8);
friendName = packet.ExtractString(13, 10, Encoding.UTF8);
clientPlayerId = packet.TryMakeWordBigEndian(24);
clientServerId = packet.TryMakeWordBigEndian(26);
friendPlayerId = packet.TryMakeWordBigEndian(28);
friendServerId = packet.TryMakeWordBigEndian(30);
}
Extract(packet.Span);
var roomId = await this._chatServer.CreateChatRoomAsync().ConfigureAwait(false);
this._logger.LogDebug($"Received request to create chat room for {clientName} and {friendName}; Room-ID: {roomId}; Client-ID: {clientPlayerId}; Server-ID: {clientServerId}; Friend-ID: {friendPlayerId}; Friend-Server: {friendServerId}");
var requesterAuthentication = await this._chatServer.RegisterClientAsync(roomId, clientName).ConfigureAwait(false);
var friendAuthentication = await this._chatServer.RegisterClientAsync(roomId, friendName).ConfigureAwait(false);
if (requesterAuthentication is not null)
{
await this.SendAuthenticationAsync(requesterAuthentication, friendAuthentication, clientPlayerId, clientServerId, requesterAuthentication.Index).ConfigureAwait(false);
}
if (friendAuthentication is not null)
{
await this.SendAuthenticationAsync(friendAuthentication, requesterAuthentication, friendPlayerId, friendServerId, friendAuthentication.Index).ConfigureAwait(false);
}
}
/// <summary>
/// Sends the authentication information back to the ExDB-Server.
/// </summary>
/// <param name="authenticationInfo">The authentication information.</param>
/// <param name="friendAuthenticationInfo">The friend authentication information.</param>
/// <param name="clientId">The client identifier on the server where the client plays on.</param>
/// <param name="serverId">The server identifier where the client plays on.</param>
/// <param name="type">The type. Usually 0 for the player who requested the chat and 1 for the other player.</param>
private async ValueTask SendAuthenticationAsync(ChatServerAuthenticationInfo authenticationInfo, ChatServerAuthenticationInfo? friendAuthenticationInfo, ushort clientId, ushort serverId, byte type)
{
this._logger.LogDebug($"Registered client {authenticationInfo.ClientName} with index {authenticationInfo.Index} and token {authenticationInfo.AuthenticationToken}");
var token = uint.Parse(authenticationInfo.AuthenticationToken);
uint friendToken = 0;
if (friendAuthenticationInfo != null)
{
friendToken = uint.Parse(friendAuthenticationInfo.AuthenticationToken);
}
var roomId = authenticationInfo.RoomId;
int Write()
{
var length = 0x2C;
var packet = this._connection!.Output.GetSpan(length);
packet[0] = 0xC1;
packet[1] = 0x2C;
packet[2] = 0xA0;
packet[3] = 0x01;
WriteUInt16LittleEndian(packet.Slice(4), roomId);
packet.Slice(6).WriteString(authenticationInfo.ClientName, Encoding.UTF8);
if (friendAuthenticationInfo != null)
{
packet.Slice(16).WriteString(friendAuthenticationInfo.ClientName, Encoding.UTF8);
}
WriteUInt16LittleEndian(packet.Slice(26), clientId);
WriteUInt16LittleEndian(packet.Slice(28), serverId);
WriteUInt32LittleEndian(packet.Slice(32), token);
WriteUInt32LittleEndian(packet.Slice(36), friendToken);
packet[40] = type;
return length;
}
await this._connection!.SendAsync(Write).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AssemblyName>ChatServer</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>bin\Debug\</OutputPath>
<DocumentationFile>bin\Debug\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>bin\Release\</OutputPath>
<DocumentationFile>bin\Release\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
<ProjectReference Include="..\MUnique.OpenMU.ChatServer.csproj" />
<ProjectReference Include="..\..\Network\MUnique.OpenMU.Network.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ChatServer.cfg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AssemblyName>ChatServer</AssemblyName>
<PlatformTarget>AnyCPU</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>bin\Debug\</OutputPath>
<DocumentationFile>bin\Debug\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>bin\Release\</OutputPath>
<DocumentationFile>bin\Release\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Settings.Configuration" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
<ProjectReference Include="..\MUnique.OpenMU.ChatServer.csproj" />
<ProjectReference Include="..\..\Network\MUnique.OpenMU.Network.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ChatServer.cfg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,79 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
using System.ComponentModel.Design;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using MUnique.OpenMU.ChatServer;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.PlugIns;
using MUnique.OpenMU.PlugIns;
using Serilog;
using Serilog.Debugging;
/// <summary>
/// The main entry class of the application.
/// </summary>
internal class Program
{
private static ILogger<Program> _logger = NullLogger<Program>.Instance;
/// <summary>
/// The main entry point for the application.
/// </summary>
/// <param name="args">The arguments. </param>
internal static async Task Main(string[] args)
{
SelfLog.Enable(Console.Error);
var logConfiguration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", false, true)
.Build();
var logger = new LoggerConfiguration()
.ReadFrom.Configuration(logConfiguration)
.CreateLogger();
var loggerFactory = new LoggerFactory().AddSerilog(logger);
_logger = loggerFactory.CreateLogger<Program>();
var addressResolver = IpAddressResolverFactory.CreateIpResolver(args, null, loggerFactory);
var settings = new Settings("ChatServer.cfg");
var serviceContainer = new ServiceContainer();
serviceContainer.AddService(typeof(ILoggerFactory), loggerFactory);
int chatServerListenerPort = settings.ChatServerListenerPort ?? 55980;
int exDbPort = settings.ExDbPort ?? 55906;
string exDbHost = settings.ExDbHost ?? "127.0.0.1";
try
{
// To make the chat server use our configured encryption key, we need to trick a bit. We add an endpoint with a special client version which is defined in the plugin.
var configuration = new ChatServerSettings();
configuration.Endpoints.Add(new ChatServerEndpoint { ClientVersion = ConfigurableNetworkEncryptionPlugIn.Version, NetworkPort = chatServerListenerPort });
var pluginManager = new PlugInManager(null, loggerFactory, serviceContainer, null);
pluginManager.DiscoverAndRegisterPlugInsOf<INetworkEncryptionFactoryPlugIn>();
var chatServer = new ChatServer(addressResolver, loggerFactory, pluginManager);
chatServer.Initialize(configuration);
await chatServer.StartAsync().ConfigureAwait(false);
var exDbClient = new ExDbClient(exDbHost, exDbPort, chatServer, chatServerListenerPort, loggerFactory);
_logger.LogInformation("ChatServer started and ready");
while (Console.ReadLine() != "exit")
{
// keep application running
}
await exDbClient.DisconnectAsync().ConfigureAwait(false);
await chatServer.ShutdownAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Unexpected error occured");
}
}
}

View File

@@ -0,0 +1,12 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
using System.Runtime.CompilerServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ChatServer.ExDbConnector")]
[assembly: InternalsVisibleTo("MUnique.OpenMU.ChatServer.ExDbConnector.Tests")]

View File

@@ -0,0 +1,156 @@
# ChatServer ExDB Connector
This isn't directly a part of the OpenMU project. It's more like a side product
to make the ChatServer available to users of the 'classical' private MU Servers.
They have - or maybe had :) - the problem that they are bound to use the
original closed source ChatServer of Webzen, if they get it working at all.
So to offer an open source alternative to the original ChatServer of Webzen,
you can use this project to connect the OpenMU-ChatServer with your 'classic'
ExDB server.
## Configuration
To make this work correctly with an existing ExDB-Server, some might do some
minor adjustments in the configuration.
It's all configured in the ChatServer.cfg an should be self-explanatory.
### ChatServerListenerPort
It's the port to which the game clients should connect. Default is 55980,
but I'm not sure if it can be changed without modifying the client.
### ExDbHost and Port
The host and tcp port of the ExDB server. Usually it's on the same server, so
127.0.0.1 on port 55906.
### Xor32Key
This one is actually very important to get right. Otherwise, the game clients
will not be able to connect.
It's the same XOR32 key which is used for the 0xC1 packet encryption from game
client to game server.
You can't edit this key at the original ChatServer of Webzen, that's the reason
why it's pretty hard to get the ChatServer working on a private server.
## Communication between ExDB-Server and ChatServer
The ExDB server usually leaves the tcp port 55906 open, so that the ChatServer
(and maybe other kind of subservers?) can connect to it.
### Registration
When the ChatServer connects to the ExDB server, it sends a data packet to
register itself. It has the following struture:
| Length | Data type | Value | Description |
|----------|---------|-------------|---------|
| 1 | byte | 0xC1 | Packet header - type |
| 1 | byte | 0x3A | Packet header - length of the packet |
| 1 | byte | 0x00 | Packet Type "server registration" |
| 1 | byte | 0x02 | Id for "ChatServer" |
| 2 | ushort | 0xDAAC | ChatServer client port (default: 55980) |
| 11 | string | "ChatServer" | ChatServer name |
Example: C1 3A 00 02 AC DA 43 68 61 74 53 65 72 76 65 72 00
From now, the ChatServer will receive chat room creation and invitation
requests from the ExDB Server, which were previously requested by the players.
### Chat Room Creation Request
When a client requests to create a new chat room, the following data packet is
sent from the ExDB Server to the ChatServer.
| Length | Data type | Value | Description |
|----------|---------|-------------|---------|
| 1 | byte | 0xC1 | Packet header - type |
| 1 | byte | 0x25 | Packet header - length of the packet |
| 1 | byte | 0xA0 | Packet Type 'chat room creation' |
| 10 | string | | Name of the character who wants to create the room |
| 10 | string | | Name of the character who should be invited to the room |
| 1 | byte | 0x01 | "Type", not relevant? |
| 2 | ushort | | Player id of the character who wants to create the room, big endian |
| 2 | ushort | | Server id of the character who wants to create the room, big endian |
| 2 | ushort | | Player id of the character who should be invited, big endian |
| 2 | ushort | | Server id of the character who should be invited, big endian |
Example:
C1 25 A0 41 42 43 44 45
46 47 48 49 4A 50 51 52
53 54 55 56 57 58 59 01
20 01 00 01 20 02 00 01
### Chat Room Creation Responses
For each of both players, there is one data packet sent back to the ExDB Server:
| Length | Data type | Value | Description |
|----------|---------|-------------|---------|
| 1 | byte | 0xC1 | Packet header - type |
| 1 | byte | 0x2C | Packet header - length of the packet |
| 1 | byte | 0xA0 | Packet Type 'chat room creation' |
| 1 | byte | 0x01 | Success flag |
| 2 | ushort | | Chat room id, big endian |
| 10 | string | | Name of the character to which a chat room invitation should be sent |
| 10 | string | | Name of the chat partner character |
| 2 | ushort | | Player id of the character to which a chat room invitation should be sent, big endian |
| 2 | ushort | | Server id of the character to which a chat room invitation should be sent, big endian |
| 2 | byte | | Padding bytes for the alignment of the following authentication token |
| 4 | uint | | Authentication token of the character to which a chat room invitation should be sent, big endian |
| 4 | uint | | Authentication token of the chat partner, big endian |
| 1 | byte | | 'Type' |
| 3 | byte | | Don't know - padding?|
Example First Player:
C1 2C A0 01 00 00 41 42
43 44 45 46 47 48 49 4A
50 51 52 53 54 55 56 57
58 59 00 00 00 00 CC CC
00 00 11 04 01 00 BB 05
00 CC CC CC
Example Second Player:
C1 2C A0 01 00 00 50 51
52 53 54 55 56 57 58 59
41 42 43 44 45 46 47 48
49 4A 00 00 00 00 CC CC
01 00 BB 05 00 00 11 04
01 CC CC CC
### Chat Room Invitation Request
When a client requests to invite another friend to an existing chat room, the
following data packet is sent from the ExDB Server to the ChatServer.
| Length | Data type | Value | Description |
|----------|---------|-------------|---------|
| 1 | byte | 0xC1 | Packet header - type |
| 1 | byte | 0x16 | Packet header - length of the packet |
| 1 | byte | 0xA1 | Packet Type 'chat room invitation' |
| 1 | byte | 0x00 | Padding |
| 2 | ushort | | Chat room id, big endian |
| 10 | string | | Name of the character who should be invited to the room |
| 2 | ushort | | Player id of the character to which a chat room invitation should be sent, big endian |
| 2 | ushort | | Server id of the character to which a chat room invitation should be sent, big endian |
| 1 | byte | | 'Type' |
Example:
C1 15 A1 00 00 00 61 62
63 64 65 66 67 68 69 6F
01 20 01 00 57
The ChatServer answers this with the same packet as above, but without filling
the second character name - no wonder, there is more than one player in the
room already.
Example:
C1 2C A0 01 00 00 61 62
63 64 65 66 67 68 69 6F
CC CC CC CC CC CC CC CC
CC CC 01 20 01 00 CC CC
02 00 C6 05 CC CC CC CC
57 CC CC CC

View File

@@ -0,0 +1,121 @@
// <copyright file="Settings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
using System.Globalization;
using System.IO;
/// <summary>
/// A class which reads settings from a file.
/// Line Format:
/// [Key]=[Value]
/// Line comments can be added by starting with "#".
/// </summary>
internal class Settings
{
private readonly IDictionary<string, string> _settingsDictionary = new Dictionary<string, string>();
/// <summary>
/// Initializes a new instance of the <see cref="Settings"/> class.
/// Reads the file contents in, if the file is available.
/// </summary>
/// <param name="file">The file.</param>
public Settings(string file)
{
if (!File.Exists(file))
{
return;
}
foreach (var line in File.ReadAllLines(file))
{
var elements = line.Split('=');
if (elements.Length > 1
&& !elements[0].StartsWith("#", StringComparison.InvariantCulture)
&& !this._settingsDictionary.ContainsKey(elements[0]))
{
this._settingsDictionary.Add(elements[0], elements[1]);
}
}
}
/// <summary>
/// Gets the configured chat server listener port.
/// </summary>
public int? ChatServerListenerPort
{
get
{
if (this["ChatServerListenerPort"] != null && int.TryParse(this["ChatServerListenerPort"], out var result))
{
return result;
}
return default;
}
}
/// <summary>
/// Gets the configured exDb server port.
/// </summary>
public int? ExDbPort
{
get
{
if (this["ExDbPort"] != null)
{
if (int.TryParse(this["ExDbPort"], out int result))
{
return result;
}
return default;
}
return null;
}
}
/// <summary>
/// Gets the configured exDb server host.
/// </summary>
public string? ExDbHost => this["ExDbHost"];
/// <summary>
/// Gets the configured xor32 key.
/// </summary>
public byte[]? Xor32Key
{
get
{
if (this["Xor32Key"] != null)
{
var customXor32KeyList = new List<byte>();
var keyAsString = this["Xor32Key"];
if (keyAsString is not null)
{
var bytesAsString = keyAsString.Split(' ');
foreach (var byteString in bytesAsString)
{
customXor32KeyList.Add(byte.Parse(byteString, NumberStyles.HexNumber, CultureInfo.InvariantCulture));
}
}
return customXor32KeyList.ToArray();
}
return null;
}
}
private string? this[string key]
{
get
{
this._settingsDictionary.TryGetValue(key, out var value);
return value;
}
}
}

View File

@@ -0,0 +1,38 @@
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Fatal",
"System": "Fatal",
"Npgsql": "Information",
"MUnique.OpenMU.Network.Connection": "Error",
"MUnique": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] [{SourceContext}] {Message}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/log.txt",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] [{SourceContext}] [{EventId}] {Message}{NewLine}{Exception}",
"rollOnFileSizeLimit": true,
"fileSizeLimitBytes": 4194304,
"retainedFileCountLimit": 48,
"rollingInterval": "Hour"
}
}
],
"Enrich": [ "FromLogContext" ],
"Properties": {
"Application": "MUnique.OpenMU.ChatServer.ExDbConnector"
}
}
}

View File

@@ -0,0 +1,73 @@
// <copyright file="IChatClient.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ChatServer;
/// <summary>
/// Type of the chat room client update message.
/// </summary>
public enum ChatRoomClientUpdateType : byte
{
/// <summary>
/// A client joined the chat room. Then the client which receives the message adds this client to it's local chat room client list.
/// </summary>
Joined = 0,
/// <summary>
/// A client left the chat room. Then the client which receives the message removes this client from it's local chat room client list.
/// </summary>
Left = 1,
}
/// <summary>
/// Interface for a chat client.
/// </summary>
public interface IChatClient
{
/// <summary>
/// Gets or sets the index of the client in the room.
/// </summary>
byte Index { get; set; }
/// <summary>
/// Gets the authentication token which was sent by the client.
/// </summary>
string? AuthenticationToken { get; }
/// <summary>
/// Gets or sets the nickname.
/// </summary>
string? Nickname { get; set; }
/// <summary>
/// Gets the last activity.
/// </summary>
DateTime LastActivity { get; }
/// <summary>
/// Logs the chat client off, which means it removes it from it's current chat room and closes the connection.
/// </summary>
ValueTask LogOffAsync();
/// <summary>
/// Sends the message to this chat client.
/// </summary>
/// <param name="senderId">The sender identifier.</param>
/// <param name="message">The message.</param>
ValueTask SendMessageAsync(byte senderId, string message);
/// <summary>
/// Sends the client list of the chat room to this client.
/// </summary>
/// <param name="clients">The chat room clients.</param>
ValueTask SendChatRoomClientListAsync(IReadOnlyCollection<IChatClient> clients);
/// <summary>
/// Notifies the client that another client has joined the chat room.
/// </summary>
/// <param name="updatedClientId">The joined client identifier.</param>
/// <param name="updatedClientName">Name of the joined client.</param>
/// <param name="updateType">Type of the update (join or leave).</param>
ValueTask SendChatRoomClientUpdateAsync(byte updatedClientId, string updatedClientName, ChatRoomClientUpdateType updateType);
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\bin\Debug\MUnique.OpenMU.ChatServer.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\bin\Release\MUnique.OpenMU.ChatServer.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ExDbConnector\**" />
<EmbeddedResource Remove="ExDbConnector\**" />
<None Remove="ExDbConnector\**" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
<ProjectReference Include="..\Network\MUnique.OpenMU.Network.csproj" />
<ProjectReference Include="..\Network\Packets\MUnique.OpenMU.Network.Packets.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
using System.Runtime.CompilerServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ChatServer")]
[assembly: InternalsVisibleTo("MUnique.OpenMU.ChatServer.Tests")]

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<PlatformTarget>AnyCPU</PlatformTarget>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>bin\Debug\</OutputPath>
<DocumentationFile>bin\Debug\MUnique.OpenMU.ClientErrorLogDecryptor.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>bin\Release\</OutputPath>
<DocumentationFile>bin\Release\MUnique.OpenMU.ClientErrorLogDecryptor.xml</DocumentationFile>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,41 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.IO;
var logPath = args.FirstOrDefault();
if (string.IsNullOrWhiteSpace(logPath))
{
Console.WriteLine("No path to the error log specified as first parameter. Press any key to exit...");
Console.ReadKey();
return;
}
var decryptionKey = new byte[] { 0x7C, 0xBD, 0x81, 0x9F, 0x3D, 0x93, 0xE2, 0x56, 0x2A, 0x73, 0xD2, 0x3E, 0xF2, 0x83, 0x95, 0xBF };
try
{
var fileBytes = await File.ReadAllBytesAsync(logPath).ConfigureAwait(false);
var resultBuilder = new StringBuilder();
var i = 0;
foreach (var character in fileBytes)
{
var result = (char)(character ^ decryptionKey[i % decryptionKey.Length]);
resultBuilder.Append(result);
i++;
}
var resultPath = logPath + ".decrypted.txt";
File.WriteAllText(resultPath, resultBuilder.ToString());
Console.WriteLine($"Decrypted file has been written to {resultPath}. Press any key to exit...");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error during decrypting the file '{logPath}':");
Console.WriteLine(ex);
Console.WriteLine($"Press any key to exit...");
}
Console.ReadKey();

View File

@@ -0,0 +1,17 @@
# Client Error Log Decryptor
This tool can be used to decrypt the MuError.log files which are created by the
game client on every start.
It might help to find/analyse errors which occured on the client side.
## Usage
This is a command line tool. The file name is given as first parameter. If it
contains spaces, wrap it by quotes.
Example: ```MUnique.OpenMU.ClientErrorLogDecryptor.exe "C:\MU-Season 6E3\MuError.log"```
On windows you can also just simply drag & drop your MuError.log file on the exe.
The resulting file is written out into a new file, where the file name is extended
by ".decrypted.txt".

View File

@@ -0,0 +1,21 @@
// <copyright file="ClientColorDepth.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// The available color depths for the mu online client.
/// </summary>
public enum ClientColorDepth
{
/// <summary>
/// The 16 bit color depth.
/// </summary>
Bit16 = 0,
/// <summary>
/// The 32 bit color depth.
/// </summary>
Bit32 = 1,
}

View File

@@ -0,0 +1,27 @@
// <copyright file="ClientLanguage.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// The available mu online client languages.
/// Only applicable for the global (english) client.
/// </summary>
public enum ClientLanguage
{
/// <summary>
/// The english language.
/// </summary>
English = 0,
/// <summary>
/// The portuguese language.
/// </summary>
Portuguese = 1,
/// <summary>
/// The spanish language.
/// </summary>
Spanish = 2,
}

View File

@@ -0,0 +1,37 @@
// <copyright file="ClientLanguageExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// Extensions for <see cref="ClientLanguage"/>.
/// </summary>
public static class ClientLanguageExtensions
{
/// <summary>
/// The language keys used in the registry.
/// </summary>
private static readonly string[] LanguageKeys = { "Eng", "Por", "Spn" };
/// <summary>
/// Gets the string which should be set in the registry for the given <see cref="ClientLanguage"/>.
/// </summary>
/// <param name="clientLanguage">The client language.</param>
/// <returns>The string which should be set in the registry for the given <see cref="ClientLanguage"/>.</returns>
public static string? GetString(this ClientLanguage clientLanguage)
{
var index = (int)clientLanguage;
return index < LanguageKeys.Length ? LanguageKeys[index] : null;
}
/// <summary>
/// Gets the <see cref="ClientLanguage"/> based on the given key, which is set in the registry.
/// </summary>
/// <param name="languageKey">The language key.</param>
/// <returns>The <see cref="ClientLanguage"/> based on the given key, which is set in the registry.</returns>
public static ClientLanguage GetLanguage(this string languageKey)
{
return (ClientLanguage)Array.IndexOf(LanguageKeys, languageKey);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="ClientResolution.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// The available mu online client screen resolutions.
/// </summary>
public class ClientResolution
{
/// <summary>
/// Initializes a new instance of the <see cref="ClientResolution"/> class.
/// </summary>
public ClientResolution()
{
this.Index = 0;
this.Caption = string.Empty;
}
/// <summary>
/// Initializes a new instance of the <see cref="ClientResolution"/> class.
/// </summary>
/// <param name="index">The index.</param>
/// <param name="caption">The caption.</param>
public ClientResolution(int index, string caption)
{
this.Index = index;
this.Caption = caption;
}
/// <summary>
/// Gets the index.
/// </summary>
public int Index { get; init; }
/// <summary>
/// Gets the caption.
/// </summary>
public string Caption { get; init; }
}

View File

@@ -0,0 +1,94 @@
// <copyright file="ClientSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.Runtime.Versioning;
using Microsoft.Win32;
/// <summary>
/// This class allows to read and write the game client settings in the registry.
/// </summary>
internal class ClientSettings
{
private const string DefaultLanguage = "Eng";
/// <summary>
/// Gets or sets the client color depth.
/// </summary>
public ClientColorDepth ClientColorDepth { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is music enabled.
/// </summary>
/// <value>
/// <c>true</c> if this instance is music enabled; otherwise, <c>false</c>.
/// </value>
public bool IsMusicEnabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is sound enabled.
/// </summary>
/// <value>
/// <c>true</c> if this instance is sound enabled; otherwise, <c>false</c>.
/// </value>
public bool IsSoundEnabled { get; set; }
/// <summary>
/// Gets or sets the volume level.
/// </summary>
public int VolumeLevel { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is window mode active.
/// </summary>
/// <value>
/// <c>true</c> if this instance is window mode active; otherwise, <c>false</c>.
/// </value>
public bool IsWindowModeActive { get; set; }
/// <summary>
/// Gets or sets the resolution.
/// </summary>
public int ResolutionIndex { get; set; }
/// <summary>
/// Gets or sets the language selection.
/// </summary>
public ClientLanguage LangSelection { get; set; }
/// <summary>
/// Loads the settings from the windows registry.
/// </summary>
[SupportedOSPlatform("windows")]
public void Load()
{
using var currentUserKey = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32);
using var key = currentUserKey.CreateSubKey(@"SOFTWARE\WebZen\Mu\Config");
this.ClientColorDepth = (ClientColorDepth)(key.GetValue("ColorDepth") ?? 0);
this.IsMusicEnabled = (int)(key.GetValue("MusicOnOff") ?? 0) == 1;
this.IsSoundEnabled = (int)(key.GetValue("SoundOnOff") ?? 0) == 1;
this.VolumeLevel = (int)(key.GetValue("VolumeLevel") ?? 0);
this.IsWindowModeActive = (int)(key.GetValue("WindowMode") ?? 0) == 1;
this.ResolutionIndex = (int)(key.GetValue("Resolution") ?? 0);
this.LangSelection = ((string?)key.GetValue("LangSelection") ?? DefaultLanguage).GetLanguage();
}
/// <summary>
/// Saves the settings at the windows registry.
/// </summary>
[SupportedOSPlatform("windows")]
public void Save()
{
using var currentUserKey = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry32);
using var key = currentUserKey.CreateSubKey(@"SOFTWARE\WebZen\Mu\Config");
key.SetValue("ColorDepth", this.ClientColorDepth, RegistryValueKind.DWord);
key.SetValue("MusicOnOff", this.IsMusicEnabled, RegistryValueKind.DWord);
key.SetValue("SoundOnOff", this.IsSoundEnabled, RegistryValueKind.DWord);
key.SetValue("VolumeLevel", this.VolumeLevel, RegistryValueKind.DWord);
key.SetValue("WindowMode", this.IsWindowModeActive, RegistryValueKind.DWord);
key.SetValue("Resolution", this.ResolutionIndex, RegistryValueKind.DWord);
key.SetValue("LangSelection", this.LangSelection.GetString() ?? DefaultLanguage, RegistryValueKind.String);
}
}

View File

@@ -0,0 +1,248 @@
namespace MUnique.OpenMU.ClientLauncher
{
partial class ClientSettingsDialog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
windowModeCheckBox = new System.Windows.Forms.CheckBox();
musicActiveCheckBox = new System.Windows.Forms.CheckBox();
soundActiveCheckBox = new System.Windows.Forms.CheckBox();
groupBox3 = new System.Windows.Forms.GroupBox();
soundVolumeTrackBar = new System.Windows.Forms.TrackBar();
groupBox4 = new System.Windows.Forms.GroupBox();
clientLanguageComboBox = new System.Windows.Forms.ComboBox();
saveButton = new System.Windows.Forms.Button();
closeButton = new System.Windows.Forms.Button();
groupBox5 = new System.Windows.Forms.GroupBox();
clientResolutionComboBox = new System.Windows.Forms.ComboBox();
groupBox1 = new System.Windows.Forms.GroupBox();
colorDepthComboBox = new System.Windows.Forms.ComboBox();
groupBox3.SuspendLayout();
((System.ComponentModel.ISupportInitialize)soundVolumeTrackBar).BeginInit();
groupBox4.SuspendLayout();
groupBox5.SuspendLayout();
groupBox1.SuspendLayout();
SuspendLayout();
//
// windowModeCheckBox
//
windowModeCheckBox.AutoSize = true;
windowModeCheckBox.Location = new System.Drawing.Point(22, 286);
windowModeCheckBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
windowModeCheckBox.Name = "windowModeCheckBox";
windowModeCheckBox.Size = new System.Drawing.Size(104, 19);
windowModeCheckBox.TabIndex = 0;
windowModeCheckBox.Text = "Window Mode";
windowModeCheckBox.UseVisualStyleBackColor = true;
//
// musicActiveCheckBox
//
musicActiveCheckBox.AutoSize = true;
musicActiveCheckBox.Location = new System.Drawing.Point(136, 22);
musicActiveCheckBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
musicActiveCheckBox.Name = "musicActiveCheckBox";
musicActiveCheckBox.Size = new System.Drawing.Size(125, 19);
musicActiveCheckBox.TabIndex = 3;
musicActiveCheckBox.Text = "Background Music";
musicActiveCheckBox.UseVisualStyleBackColor = true;
//
// soundActiveCheckBox
//
soundActiveCheckBox.AutoSize = true;
soundActiveCheckBox.Location = new System.Drawing.Point(10, 22);
soundActiveCheckBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
soundActiveCheckBox.Name = "soundActiveCheckBox";
soundActiveCheckBox.Size = new System.Drawing.Size(98, 19);
soundActiveCheckBox.TabIndex = 4;
soundActiveCheckBox.Text = "Sound Effects";
soundActiveCheckBox.UseVisualStyleBackColor = true;
//
// groupBox3
//
groupBox3.Controls.Add(soundVolumeTrackBar);
groupBox3.Controls.Add(musicActiveCheckBox);
groupBox3.Controls.Add(soundActiveCheckBox);
groupBox3.Location = new System.Drawing.Point(19, 115);
groupBox3.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox3.Name = "groupBox3";
groupBox3.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox3.Size = new System.Drawing.Size(315, 111);
groupBox3.TabIndex = 7;
groupBox3.TabStop = false;
groupBox3.Text = "Sound";
//
// soundVolumeTrackBar
//
soundVolumeTrackBar.Dock = System.Windows.Forms.DockStyle.Bottom;
soundVolumeTrackBar.Location = new System.Drawing.Point(4, 63);
soundVolumeTrackBar.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
soundVolumeTrackBar.Name = "soundVolumeTrackBar";
soundVolumeTrackBar.Size = new System.Drawing.Size(307, 45);
soundVolumeTrackBar.TabIndex = 5;
//
// groupBox4
//
groupBox4.Controls.Add(clientLanguageComboBox);
groupBox4.Location = new System.Drawing.Point(19, 233);
groupBox4.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox4.Name = "groupBox4";
groupBox4.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox4.Size = new System.Drawing.Size(315, 46);
groupBox4.TabIndex = 8;
groupBox4.TabStop = false;
groupBox4.Text = "Language";
//
// clientLanguageComboBox
//
clientLanguageComboBox.Dock = System.Windows.Forms.DockStyle.Bottom;
clientLanguageComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
clientLanguageComboBox.FormattingEnabled = true;
clientLanguageComboBox.Items.AddRange(new object[] { "English", "Portuguese", "Spanish" });
clientLanguageComboBox.Location = new System.Drawing.Point(4, 20);
clientLanguageComboBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
clientLanguageComboBox.Name = "clientLanguageComboBox";
clientLanguageComboBox.Size = new System.Drawing.Size(307, 23);
clientLanguageComboBox.TabIndex = 0;
//
// saveButton
//
saveButton.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
saveButton.DialogResult = System.Windows.Forms.DialogResult.OK;
saveButton.Location = new System.Drawing.Point(153, 321);
saveButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
saveButton.Name = "saveButton";
saveButton.Size = new System.Drawing.Size(88, 27);
saveButton.TabIndex = 9;
saveButton.Text = "OK";
saveButton.UseVisualStyleBackColor = true;
saveButton.Click += SaveButtonClick;
//
// closeButton
//
closeButton.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
closeButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
closeButton.Location = new System.Drawing.Point(247, 321);
closeButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
closeButton.Name = "closeButton";
closeButton.Size = new System.Drawing.Size(88, 27);
closeButton.TabIndex = 10;
closeButton.Text = "Cancel";
closeButton.UseVisualStyleBackColor = true;
//
// groupBox5
//
groupBox5.Controls.Add(clientResolutionComboBox);
groupBox5.Location = new System.Drawing.Point(19, 13);
groupBox5.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox5.Name = "groupBox5";
groupBox5.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox5.Size = new System.Drawing.Size(315, 45);
groupBox5.TabIndex = 11;
groupBox5.TabStop = false;
groupBox5.Text = "Screen Resolution";
//
// clientResolutionComboBox
//
clientResolutionComboBox.Dock = System.Windows.Forms.DockStyle.Bottom;
clientResolutionComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
clientResolutionComboBox.FormattingEnabled = true;
clientResolutionComboBox.Location = new System.Drawing.Point(4, 19);
clientResolutionComboBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
clientResolutionComboBox.Name = "clientResolutionComboBox";
clientResolutionComboBox.Size = new System.Drawing.Size(307, 23);
clientResolutionComboBox.TabIndex = 1;
//
// groupBox1
//
groupBox1.Controls.Add(colorDepthComboBox);
groupBox1.Location = new System.Drawing.Point(19, 65);
groupBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox1.Name = "groupBox1";
groupBox1.Padding = new System.Windows.Forms.Padding(4, 3, 4, 3);
groupBox1.Size = new System.Drawing.Size(316, 44);
groupBox1.TabIndex = 12;
groupBox1.TabStop = false;
groupBox1.Text = "Color Depth";
//
// colorDepthComboBox
//
colorDepthComboBox.Dock = System.Windows.Forms.DockStyle.Bottom;
colorDepthComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
colorDepthComboBox.FormattingEnabled = true;
colorDepthComboBox.Items.AddRange(new object[] { "Min Color (16 bit)", "Max Color (32 bit)" });
colorDepthComboBox.Location = new System.Drawing.Point(4, 18);
colorDepthComboBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
colorDepthComboBox.Name = "colorDepthComboBox";
colorDepthComboBox.Size = new System.Drawing.Size(308, 23);
colorDepthComboBox.TabIndex = 2;
//
// ClientSettingsDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
CancelButton = closeButton;
ClientSize = new System.Drawing.Size(349, 361);
Controls.Add(groupBox1);
Controls.Add(groupBox5);
Controls.Add(closeButton);
Controls.Add(saveButton);
Controls.Add(groupBox4);
Controls.Add(groupBox3);
Controls.Add(windowModeCheckBox);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
Name = "ClientSettingsDialog";
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Change Client Settings";
groupBox3.ResumeLayout(false);
groupBox3.PerformLayout();
((System.ComponentModel.ISupportInitialize)soundVolumeTrackBar).EndInit();
groupBox4.ResumeLayout(false);
groupBox5.ResumeLayout(false);
groupBox1.ResumeLayout(false);
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.CheckBox windowModeCheckBox;
private System.Windows.Forms.CheckBox musicActiveCheckBox;
private System.Windows.Forms.CheckBox soundActiveCheckBox;
private System.Windows.Forms.GroupBox groupBox3;
private System.Windows.Forms.GroupBox groupBox4;
private System.Windows.Forms.ComboBox clientLanguageComboBox;
private System.Windows.Forms.Button saveButton;
private System.Windows.Forms.TrackBar soundVolumeTrackBar;
private System.Windows.Forms.Button closeButton;
private System.Windows.Forms.GroupBox groupBox5;
private System.Windows.Forms.ComboBox clientResolutionComboBox;
private System.Windows.Forms.GroupBox groupBox1;
private System.Windows.Forms.ComboBox colorDepthComboBox;
}
}

View File

@@ -0,0 +1,83 @@
// <copyright file="ClientSettingsDialog.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.ComponentModel;
using System.Drawing;
using System.Runtime.Versioning;
using System.Windows.Forms;
/// <summary>
/// <see cref="ClientSettingsDialog"/>.
/// </summary>
[SupportedOSPlatform("windows")]
internal partial class ClientSettingsDialog : Form
{
/// <summary>
/// Initializes a new instance of the <see cref="ClientSettingsDialog"/> class.
/// </summary>
public ClientSettingsDialog()
{
this.InitializeComponent();
this.clientResolutionComboBox.DisplayMember = nameof(ClientResolution.Caption);
this.clientResolutionComboBox.ValueMember = nameof(ClientResolution.Index);
this.clientResolutionComboBox.DataSource = LauncherSettings.DefaultResolutions;
this.Icon = Icon.FromHandle(Properties.Resources.Settings_16x.GetHicon());
var config = new ClientSettings();
config.Load();
this.ReadConfig(config);
}
/// <summary>
/// Gets or sets the available client resolutions.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public BindingList<ClientResolution>? Resolutions
{
get => this.clientResolutionComboBox.DataSource as BindingList<ClientResolution>;
set
{
if (value?.Count > 0)
{
var index = this.clientResolutionComboBox.SelectedIndex;
this.clientResolutionComboBox.DataSource = value;
if (value?.Count > index)
{
this.clientResolutionComboBox.SelectedIndex = index;
}
}
}
}
private void SaveButtonClick(object sender, EventArgs e)
{
var config = new ClientSettings();
this.SetConfig(config);
config.Save();
}
private void ReadConfig(ClientSettings clientSettings)
{
this.colorDepthComboBox.SelectedIndex = clientSettings.ClientColorDepth == 0 ? 0 : 1;
this.musicActiveCheckBox.Checked = clientSettings.IsMusicEnabled;
this.soundActiveCheckBox.Checked = clientSettings.IsSoundEnabled;
this.soundVolumeTrackBar.Value = clientSettings.VolumeLevel;
this.windowModeCheckBox.Checked = clientSettings.IsWindowModeActive;
this.clientResolutionComboBox.SelectedIndex = clientSettings.ResolutionIndex;
this.clientLanguageComboBox.SelectedIndex = (int)clientSettings.LangSelection;
}
private void SetConfig(ClientSettings clientSettings)
{
clientSettings.ClientColorDepth = (ClientColorDepth)this.colorDepthComboBox.SelectedIndex;
clientSettings.IsMusicEnabled = this.musicActiveCheckBox.Checked;
clientSettings.IsSoundEnabled = this.soundActiveCheckBox.Checked;
clientSettings.VolumeLevel = this.soundVolumeTrackBar.Value;
clientSettings.IsWindowModeActive = this.windowModeCheckBox.Checked;
clientSettings.ResolutionIndex = this.clientResolutionComboBox.SelectedIndex;
clientSettings.LangSelection = (ClientLanguage)this.clientLanguageComboBox.SelectedIndex;
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,142 @@
namespace MUnique.OpenMU.ClientLauncher;
partial class HostConfigurationDialog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
_serverPortControl = new System.Windows.Forms.NumericUpDown();
_serverAddressTextBox = new System.Windows.Forms.TextBox();
label2 = new System.Windows.Forms.Label();
label1 = new System.Windows.Forms.Label();
_descriptionTextBox = new System.Windows.Forms.TextBox();
button1 = new System.Windows.Forms.Button();
_cancelButton = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)_serverPortControl).BeginInit();
SuspendLayout();
//
// _serverPortControl
//
_serverPortControl.Location = new System.Drawing.Point(273, 36);
_serverPortControl.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
_serverPortControl.Maximum = new decimal(new int[] { 65535, 0, 0, 0 });
_serverPortControl.Minimum = new decimal(new int[] { 1, 0, 0, 0 });
_serverPortControl.Name = "_serverPortControl";
_serverPortControl.Size = new System.Drawing.Size(68, 23);
_serverPortControl.TabIndex = 10;
_serverPortControl.Value = new decimal(new int[] { 44405, 0, 0, 0 });
//
// _serverAddressTextBox
//
_serverAddressTextBox.Location = new System.Drawing.Point(113, 35);
_serverAddressTextBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
_serverAddressTextBox.Name = "_serverAddressTextBox";
_serverAddressTextBox.Size = new System.Drawing.Size(152, 23);
_serverAddressTextBox.TabIndex = 9;
_serverAddressTextBox.Text = "127.127.127.127";
//
// label2
//
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(13, 38);
label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
label2.Name = "label2";
label2.Size = new System.Drawing.Size(89, 15);
label2.TabIndex = 8;
label2.Text = "Server-Address:";
//
// label1
//
label1.AutoSize = true;
label1.Location = new System.Drawing.Point(13, 9);
label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(70, 15);
label1.TabIndex = 11;
label1.Text = "Description:";
//
// _descriptionTextBox
//
_descriptionTextBox.Location = new System.Drawing.Point(113, 6);
_descriptionTextBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
_descriptionTextBox.Name = "_descriptionTextBox";
_descriptionTextBox.PlaceholderText = "<Enter a description here>";
_descriptionTextBox.Size = new System.Drawing.Size(228, 23);
_descriptionTextBox.TabIndex = 12;
//
// button1
//
button1.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
button1.DialogResult = System.Windows.Forms.DialogResult.OK;
button1.Location = new System.Drawing.Point(194, 83);
button1.Name = "button1";
button1.Size = new System.Drawing.Size(75, 23);
button1.TabIndex = 13;
button1.Text = "OK";
button1.UseVisualStyleBackColor = true;
//
// _cancelButton
//
_cancelButton.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
_cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
_cancelButton.Location = new System.Drawing.Point(113, 83);
_cancelButton.Name = "_cancelButton";
_cancelButton.Size = new System.Drawing.Size(75, 23);
_cancelButton.TabIndex = 14;
_cancelButton.Text = "Cancel";
_cancelButton.UseVisualStyleBackColor = true;
//
// HostConfiguration
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(362, 118);
Controls.Add(_cancelButton);
Controls.Add(button1);
Controls.Add(_descriptionTextBox);
Controls.Add(label1);
Controls.Add(_serverPortControl);
Controls.Add(_serverAddressTextBox);
Controls.Add(label2);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow;
MinimizeBox = false;
Name = "HostConfiguration";
Text = "Configure Connection";
((System.ComponentModel.ISupportInitialize)_serverPortControl).EndInit();
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.NumericUpDown _serverPortControl;
private System.Windows.Forms.TextBox _serverAddressTextBox;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.TextBox _descriptionTextBox;
private System.Windows.Forms.Button button1;
private System.Windows.Forms.Button _cancelButton;
}

View File

@@ -0,0 +1,46 @@
// <copyright file="HostConfigurationDialog.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.ComponentModel;
using System.Windows.Forms;
/// <summary>
/// Dialog for connection settings of a server.
/// </summary>
public partial class HostConfigurationDialog : Form
{
/// <summary>
/// Initializes a new instance of the <see cref="HostConfigurationDialog"/> class.
/// </summary>
public HostConfigurationDialog()
{
this.InitializeComponent();
}
/// <summary>
/// Gets or sets the settings.
/// </summary>
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public ServerHostSettings Settings
{
get
{
return new ServerHostSettings
{
Description = this._descriptionTextBox.Text,
Address = this._serverAddressTextBox.Text,
Port = (int)this._serverPortControl.Value,
};
}
set
{
this._descriptionTextBox.Text = value.Description;
this._serverAddressTextBox.Text = value.Address;
this._serverPortControl.Value = value.Port;
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,31 @@
// <copyright file="ILauncher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// The interface of a launcher.
/// </summary>
internal interface ILauncher
{
/// <summary>
/// Gets or sets the host ip.
/// </summary>
string? HostAddress { get; set; }
/// <summary>
/// Gets or sets the host port.
/// </summary>
int HostPort { get; set; }
/// <summary>
/// Gets or sets the main executable path.
/// </summary>
string? MainExePath { get; set; }
/// <summary>
/// Launches the MU Online client with the specified settings.
/// </summary>
void LaunchClient();
}

View File

@@ -0,0 +1,183 @@
// <copyright file="Launcher.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Windows.Forms;
using Microsoft.Win32;
/// <summary>
/// The default launcher, which writes
/// * host and port into the registry (official way until season 6 at the global server)
/// * adds parameters /u and /p (works in some other versions of the game client)
/// before starting the main.exe.
/// </summary>
public class Launcher : ILauncher
{
/// <summary>
/// Gets or sets the host ip.
/// </summary>
public string? HostAddress { get; set; }
/// <summary>
/// Gets or sets the host port.
/// </summary>
public int HostPort { get; set; }
/// <summary>
/// Gets or sets the main executable path.
/// </summary>
public string? MainExePath { get; set; }
/// <summary>
/// Launches Mu with the set configuration.
/// </summary>
public void LaunchClient()
{
if (string.IsNullOrWhiteSpace(this.HostAddress))
{
MessageBox.Show("Host address is not set.", "Error");
return;
}
if (string.IsNullOrWhiteSpace(this.MainExePath))
{
MessageBox.Show("The path to the main.exe is not set.", "Error");
return;
}
if (this.ResolveHost() is not { } ipAddress)
{
MessageBox.Show($"Address '{this.HostAddress} could not be resolved to an IPv4 address.", "Error");
return;
}
if (OperatingSystem.IsWindows())
{
using var localMachineKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32);
using var key = localMachineKey.CreateSubKey(@"SOFTWARE\WebZen\Mu\Connection");
key.SetValue("Key", Environment.TickCount, RegistryValueKind.DWord);
key.SetValue("ParameterA", this.HostEncode(ipAddress), RegistryValueKind.String);
key.SetValue("ParameterB", this.PortEncode(ipAddress), RegistryValueKind.DWord);
}
else
{
if (MessageBox.Show(
"IP and Port couldn't be set, because the operating system is not windows. Try to launch the game client anyway?",
string.Empty,
MessageBoxButtons.YesNo) != DialogResult.Yes)
{
return;
}
}
var info = new DirectoryInfo(this.MainExePath!);
var startInfo = new ProcessStartInfo(this.MainExePath, ["connect", $"/u{ipAddress}", $"/p{this.HostPort}"])
{
WorkingDirectory = info.Parent!.FullName,
UseShellExecute = true,
Verb = "open",
};
if (OperatingSystem.IsWindows())
{
startInfo.LoadUserProfile = true;
}
Process.Start(startInfo);
}
/// <summary>
/// Encodes the Port, so that the main.exe can read it.
/// </summary>
/// <returns>Encoded port value which will be put into ParameterB.</returns>
private int PortEncode(string ipAddress)
{
var port = this.HostPort;
switch (ipAddress.Length % 4)
{
case 0:
port += 12 - (((port / 4) % 4) * 8);
return port;
case 1:
port += 7 - ((port % 8) * 2);
return port;
case 2:
port += 3 - ((port % 4) * 2);
return port;
case 3:
port += (0x13 - ((port % 4) * 2)) - (((port / 0x10) % 2) * 0x20);
return port;
default:
// we'll hopefully never run into this one
return port;
}
}
private string? ResolveHost()
{
if (IPAddress.TryParse(this.HostAddress, out _))
{
return this.HostAddress;
}
var entry = Dns.GetHostEntry(this.HostAddress!);
if (entry.AddressList.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork) is { } ipAddress)
{
var address = ipAddress.ToString();
// The game client blocks connections to 127.0.0.1 (probably to prevent cheating).
return address == "127.0.0.1" ? "127.127.127.127" : address;
}
return null;
}
/// <summary>
/// Encodes the IP Address of the server, so that the main.exe can read it.
/// </summary>
/// <returns>Encoded string value of the ip address which to put into ParameterA.</returns>
private string HostEncode(string ipAddress)
{
var result = new StringBuilder();
var counter = 0;
foreach (var ch in ipAddress)
{
var encodedCharacter = '\0';
counter++;
switch (counter)
{
case 1:
encodedCharacter = (char)(ch + '\f');
break;
case 2:
encodedCharacter = (char)(ch + '\a');
break;
case 3:
encodedCharacter = (char)(ch + '\x0003');
break;
case 4:
encodedCharacter = (char)(ch + '\x0013');
counter = 0;
break;
default:
// we should not run into this case, since it's always 1 to 4.
break;
}
result.Append(encodedCharacter);
}
return result.ToString();
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="LauncherSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
/// <summary>
/// Settings of the launcher.
/// </summary>
public class LauncherSettings
{
/// <summary>
/// Gets the default resolutions, which are based on the open source MuMain.
/// </summary>
public static ClientResolution[] DefaultResolutions =>
[
new(0, "640x480"),
new(1, "800x600"),
new(2, "1024x768"),
new(3, "1280x1024"),
new(4, "1600x1200"),
new(5, "1864x1400"),
new(6, "1600x900"),
new(7, "1600x1280"),
new(8, "1680x1050"),
new(9, "1920x1080"),
new(10, "2560x1440"),
];
/// <summary>
/// Gets or sets the main executable path.
/// </summary>
public string? MainExePath { get; set; }
/// <summary>
/// Gets or sets the configured hosts.
/// </summary>
public List<ServerHostSettings> Hosts { get; set; } = [];
/// <summary>
/// Gets or sets the resolutions.
/// </summary>
public List<ClientResolution> AvailableResolutions { get; set; } = [];
}

View File

@@ -0,0 +1,69 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>bin\Debug\</OutputPath>
<DocumentationFile>bin\Debug\MUnique.OpenMU.ClientLauncher.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>bin\Release\</OutputPath>
<DocumentationFile>bin\Release\MUnique.OpenMU.ClientLauncher.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<Content Include="Rocket_16x.ico" />
</ItemGroup>
<PropertyGroup>
<ApplicationManifest>MUnique.OpenMU.ClientLauncher.exe.manifest</ApplicationManifest>
<ApplicationIcon>Rocket_16x.ico</ApplicationIcon>
<StartupObject>MUnique.OpenMU.ClientLauncher.Program</StartupObject>
</PropertyGroup>
<ItemGroup>
<None Update="MUnique.OpenMU.ClientLauncher.exe.manifest">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Compile Update="MainForm.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
<Compile Update="ClientSettingsDialog.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
</Compile>
<Compile Update="MainForm.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>MainForm.cs</DependentUpon>
</Compile>
<Compile Update="ClientSettingsDialog.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>ClientSettingsDialog.cs</DependentUpon>
</Compile>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="ClientSettingsDialog.resx">
<DependentUpon>ClientSettingsDialog.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Update="MainForm.resx">
<DependentUpon>MainForm.cs</DependentUpon>
</EmbeddedResource>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on and is
is designed to work with. Uncomment the appropriate elements and Windows will
automatically selected the most compatible environment. -->
<!-- Windows Vista -->
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. -->
<!--
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
-->
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

207
src/ClientLauncher/MainForm.Designer.cs generated Normal file
View File

@@ -0,0 +1,207 @@
namespace MUnique.OpenMU.ClientLauncher
{
partial class MainForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(MainForm));
_launchButton = new System.Windows.Forms.Button();
label1 = new System.Windows.Forms.Label();
openFileDialog = new System.Windows.Forms.OpenFileDialog();
MainExePathTextBox = new System.Windows.Forms.TextBox();
SearchMainExeButton = new System.Windows.Forms.Button();
label2 = new System.Windows.Forms.Label();
configurationDialogButton = new System.Windows.Forms.Button();
_editHostButton = new System.Windows.Forms.Button();
_addHostButton = new System.Windows.Forms.Button();
_serversComboBox = new System.Windows.Forms.ComboBox();
_removeHostButton = new System.Windows.Forms.Button();
SuspendLayout();
//
// _launchButton
//
_launchButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
_launchButton.Location = new System.Drawing.Point(505, 45);
_launchButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
_launchButton.Name = "_launchButton";
_launchButton.Size = new System.Drawing.Size(147, 27);
_launchButton.TabIndex = 0;
_launchButton.Text = "Launch Client";
_launchButton.UseVisualStyleBackColor = true;
_launchButton.Click += LaunchClick;
//
// label1
//
label1.AutoSize = true;
label1.Location = new System.Drawing.Point(14, 17);
label1.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(85, 15);
label1.TabIndex = 1;
label1.Text = "main.exe Path:";
//
// openFileDialog
//
openFileDialog.FileName = "main.exe";
openFileDialog.Filter = "Executeables|*.exe";
//
// MainExePathTextBox
//
MainExePathTextBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
MainExePathTextBox.Location = new System.Drawing.Point(107, 14);
MainExePathTextBox.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MainExePathTextBox.Name = "MainExePathTextBox";
MainExePathTextBox.Size = new System.Drawing.Size(467, 23);
MainExePathTextBox.TabIndex = 2;
MainExePathTextBox.Text = "main.exe";
//
// SearchMainExeButton
//
SearchMainExeButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
SearchMainExeButton.Location = new System.Drawing.Point(582, 12);
SearchMainExeButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
SearchMainExeButton.Name = "SearchMainExeButton";
SearchMainExeButton.Size = new System.Drawing.Size(35, 27);
SearchMainExeButton.TabIndex = 3;
SearchMainExeButton.Text = "...";
SearchMainExeButton.UseVisualStyleBackColor = true;
SearchMainExeButton.Click += SearchMainExeButtonClick;
//
// label2
//
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(57, 54);
label2.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
label2.Name = "label2";
label2.Size = new System.Drawing.Size(42, 15);
label2.TabIndex = 4;
label2.Text = "Server:";
//
// configurationDialogButton
//
configurationDialogButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
configurationDialogButton.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
configurationDialogButton.Image = Properties.Resources.Settings_16x;
configurationDialogButton.Location = new System.Drawing.Point(625, 12);
configurationDialogButton.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
configurationDialogButton.Name = "configurationDialogButton";
configurationDialogButton.Size = new System.Drawing.Size(27, 27);
configurationDialogButton.TabIndex = 4;
configurationDialogButton.UseVisualStyleBackColor = true;
configurationDialogButton.Click += ConfigurationDialogButtonClick;
//
// _editHostButton
//
_editHostButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
_editHostButton.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
_editHostButton.Image = Properties.Resources.Edit_16x;
_editHostButton.Location = new System.Drawing.Point(446, 47);
_editHostButton.Margin = new System.Windows.Forms.Padding(2, 3, 2, 3);
_editHostButton.Name = "_editHostButton";
_editHostButton.Size = new System.Drawing.Size(23, 23);
_editHostButton.TabIndex = 7;
_editHostButton.UseVisualStyleBackColor = true;
_editHostButton.Click += OnEditHostButtonClick;
//
// _addHostButton
//
_addHostButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
_addHostButton.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
_addHostButton.Image = Properties.Resources.Add_16x;
_addHostButton.Location = new System.Drawing.Point(419, 47);
_addHostButton.Margin = new System.Windows.Forms.Padding(2, 3, 2, 3);
_addHostButton.Name = "_addHostButton";
_addHostButton.Size = new System.Drawing.Size(23, 23);
_addHostButton.TabIndex = 6;
_addHostButton.UseVisualStyleBackColor = true;
_addHostButton.Click += OnAddHostButtonClick;
//
// _serversComboBox
//
_serversComboBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
_serversComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
_serversComboBox.FormattingEnabled = true;
_serversComboBox.Location = new System.Drawing.Point(107, 48);
_serversComboBox.Name = "_serversComboBox";
_serversComboBox.Size = new System.Drawing.Size(307, 23);
_serversComboBox.TabIndex = 5;
_serversComboBox.SelectedValueChanged += OnServersComboBoxSelectedIndexChanged;
//
// _removeHostButton
//
_removeHostButton.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
_removeHostButton.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink;
_removeHostButton.Image = Properties.Resources.Remove_16x;
_removeHostButton.Location = new System.Drawing.Point(473, 46);
_removeHostButton.Margin = new System.Windows.Forms.Padding(2, 3, 5, 3);
_removeHostButton.Name = "_removeHostButton";
_removeHostButton.Size = new System.Drawing.Size(23, 23);
_removeHostButton.TabIndex = 8;
_removeHostButton.UseVisualStyleBackColor = true;
_removeHostButton.Click += OnRemoveHostButtonClick;
//
// MainForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(665, 82);
Controls.Add(_removeHostButton);
Controls.Add(_serversComboBox);
Controls.Add(_addHostButton);
Controls.Add(_editHostButton);
Controls.Add(configurationDialogButton);
Controls.Add(label2);
Controls.Add(SearchMainExeButton);
Controls.Add(MainExePathTextBox);
Controls.Add(label1);
Controls.Add(_launchButton);
Icon = (System.Drawing.Icon)resources.GetObject("$this.Icon");
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MaximizeBox = false;
MaximumSize = new System.Drawing.Size(1257, 121);
Name = "MainForm";
Text = "MU Game Client Launcher";
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.Button _launchButton;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.OpenFileDialog openFileDialog;
private System.Windows.Forms.TextBox MainExePathTextBox;
private System.Windows.Forms.Button SearchMainExeButton;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.Button configurationDialogButton;
private System.Windows.Forms.Button _editHostButton;
private System.Windows.Forms.Button _addHostButton;
private System.Windows.Forms.ComboBox _serversComboBox;
private System.Windows.Forms.Button _removeHostButton;
}
}

View File

@@ -0,0 +1,197 @@
// <copyright file="MainForm.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#pragma warning disable CA1416 // This project is compiled for windows
namespace MUnique.OpenMU.ClientLauncher;
using System.ComponentModel;
using System.IO;
using System.Windows.Forms;
using System.Xml.Serialization;
/// <summary>
/// The main form of the launcher.
/// </summary>
public partial class MainForm : Form
{
private const string ConfigFileName = "launcher.config";
/// <summary>
/// Gets or sets the binding list for the configured hosts.
/// </summary>
private BindingList<ServerHostSettings> _hostsBindingList = new();
/// <summary>
/// Initializes a new instance of the <see cref="MainForm"/> class.
/// </summary>
public MainForm()
{
this.InitializeComponent();
this.LoadOptions();
this.UpdateButtonStates();
}
private BindingList<ServerHostSettings> Hosts
{
get => this._hostsBindingList;
set
{
this._hostsBindingList = value;
this._serversComboBox.DataSource = value;
}
}
private BindingList<ClientResolution> Resolutions { get; set; } = new(LauncherSettings.DefaultResolutions);
/// <summary>
/// Launches the MU Online client (main.exe) to connect to the configured address.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void LaunchClick(object sender, EventArgs e)
{
try
{
var selectedHost = (ServerHostSettings)this._serversComboBox.SelectedItem!;
var launcher = new Launcher
{
HostAddress = selectedHost.Address,
HostPort = selectedHost.Port,
MainExePath = this.MainExePathTextBox.Text,
};
launcher.LaunchClient();
this.SaveCurrentOptions();
}
catch (UnauthorizedAccessException)
{
MessageBox.Show("Can't access Windows Registry. To use the launcher, run it as Administrator.");
}
catch (Exception ex)
{
MessageBox.Show("Error Starting MU. Path correct?" + Environment.NewLine + ex.Message);
}
}
private void LoadOptions()
{
this._serversComboBox.DataSource = this.Hosts;
if (!File.Exists(ConfigFileName))
{
return;
}
try
{
var reader = new XmlSerializer(typeof(LauncherSettings));
using var file = new StreamReader(ConfigFileName);
if (reader.Deserialize(file) is LauncherSettings launcherSettings)
{
this.MainExePathTextBox.Text = launcherSettings.MainExePath;
this.Hosts = new BindingList<ServerHostSettings>(launcherSettings.Hosts);
if (launcherSettings.AvailableResolutions?.Any() is true)
{
this.Resolutions = new(launcherSettings.AvailableResolutions);
}
else
{
this.Resolutions = new(LauncherSettings.DefaultResolutions);
}
}
file.Close();
}
catch
{
this.Hosts.Clear();
this.Hosts.Add(new ServerHostSettings { Description = "Local ConnectServer", Address = "localhost", Port = 44405 });
this.Hosts.Add(new ServerHostSettings { Description = "Local GameServer 1", Address = "localhost", Port = 55901 });
}
}
private void SaveCurrentOptions()
{
var settings = new LauncherSettings
{
Hosts = this._hostsBindingList.ToList(),
MainExePath = this.MainExePathTextBox.Text,
AvailableResolutions = this.Resolutions.ToList(),
};
var writer = new XmlSerializer(typeof(LauncherSettings));
using var file = File.Create(ConfigFileName);
writer.Serialize(file, settings);
file.Close();
}
private void SearchMainExeButtonClick(object sender, EventArgs e)
{
var dialogResult = this.openFileDialog.ShowDialog(this);
if (dialogResult == DialogResult.OK)
{
this.MainExePathTextBox.Text = this.openFileDialog.FileName;
}
}
private void ConfigurationDialogButtonClick(object sender, EventArgs e)
{
if (OperatingSystem.IsWindows())
{
using var configDialog = new ClientSettingsDialog();
configDialog.Resolutions = this.Resolutions;
configDialog.ShowDialog(this);
}
else
{
MessageBox.Show("Changing the configuration of the MU game client is only supported on windows.");
}
}
private void OnAddHostButtonClick(object sender, EventArgs e)
{
using var dialog = new HostConfigurationDialog();
if (dialog.ShowDialog(this) == DialogResult.OK)
{
var settings = dialog.Settings;
this._hostsBindingList.Add(settings);
this.SaveCurrentOptions();
this.UpdateButtonStates();
}
}
private void OnEditHostButtonClick(object sender, EventArgs e)
{
var selectedConfiguration = (ServerHostSettings)this._serversComboBox.SelectedItem!;
using var dialog = new HostConfigurationDialog();
dialog.Settings = selectedConfiguration;
if (dialog.ShowDialog(this) == DialogResult.OK)
{
var editedConfiguration = dialog.Settings;
selectedConfiguration.Port = editedConfiguration.Port;
selectedConfiguration.Address = editedConfiguration.Address;
selectedConfiguration.Description = editedConfiguration.Description;
this.SaveCurrentOptions();
}
}
private void OnRemoveHostButtonClick(object sender, EventArgs e)
{
this._hostsBindingList.RemoveAt(this._serversComboBox.SelectedIndex);
this.SaveCurrentOptions();
}
private void OnServersComboBoxSelectedIndexChanged(object sender, EventArgs e)
{
this.UpdateButtonStates();
}
private void UpdateButtonStates()
{
var isServerSelected = this._serversComboBox.SelectedItem is ServerHostSettings;
this._editHostButton.Enabled = isServerSelected;
this._removeHostButton.Enabled = isServerSelected;
this._launchButton.Enabled = isServerSelected;
}
}

View File

@@ -0,0 +1,303 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="openFileDialog.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="$this.Icon" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>
AAABAAEAMjIAAAEAIADIKAAAFgAAACgAAAAyAAAAZAAAAAEAIAAAAAAAECcAACMuAAAjLgAAAAAAAAAA
AAD29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/19fWfAAAAAAAAAAAAAAAAAAAAAPHx8RL29vZ09vb23Pb29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9/f3mv///wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//X19Z8AAAAAAAAAAAAAAAD39/ce9vb23fb29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9fX1nP///wIAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9fX1nwAAAAAAAAAA9/f3Hvb2
9t329vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9fX1nP///wIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ubm7/Wlpa/1paWv9aWlr/Wlpa/1paWv9aWlr/Wlpa/1paWv+oqKj/9vb2//b2
9v/19fWfAAAAAPf39yD29vbf9vb2//b29v/19fX/paWl/2BgYP9aWlr/Wlpa/1paWv9aWlr/Wlpa/1pa
Wv9aWlr/Wlpa/11dXf/MzMz/9vb2//b29v/29vb/9vb2bAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/0JCQv9CQkL/QkJC/0JC
Qv9CQkL/QkJC/5ycnP/29vb/9vb2//X19Z/4+Pgl9vb24vb29v/29vb/9fX1/42Njf9CQkL/QkJC/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0tLS//U1NT/9vb2//b29v/19fWAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/WVlZ/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/nJyc//b29v/29vb/9vb2wPb29uT29vb/9vb2//X1
9f+Pj4//QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/7Ky
sv/29vb/9vb2//X19YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ZWVn/QkJC/0JCQv+lpaX/xsbG/8bGxv/Gxsb/xsbG/8bGxv/e3t7/9vb2//b2
9v/29vb/9vb2//b29v/19fX/jY2N/0JCQv9CQkL/QkJC/2xsbP/Gxsb/xsbG/8bGxv/Gxsb/xsbG/8bG
xv/Gxsb/Y2Nj/0JCQv9CQkL/srKy//b29v/29vb/9fX1gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/8nJyf/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9fX1/4iIiP9CQkL/QkJC/0JCQv9MTEz/5+fn//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v9vb2//QkJC/0JCQv+ysrL/9vb2//b29v/19fWAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/WVlZ/0JC
Qv9CQkL/ycnJ//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//X19f+NjY3/QkJC/0JC
Qv9CQkL/QkJC/0JCQv9gYGD/2NjY//b29v/29vb/9vb2//b29v/29vb/9vb2/29vb/9CQkL/QkJC/7Ky
sv/29vb/9vb2//X19YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ZWVn/QkJC/0JCQv/Jycn/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/19fX/iIiI/0JCQv9CQkL/QkJC/0lJSf9CQkL/QkJC/0JCQv9MTEz/ubm5//b29v/29vb/9vb2//b2
9v/29vb/b29v/0JCQv9CQkL/srKy//b29v/29vb/9fX1gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/8nJyf/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9fX1/4iIiP9CQkL/QkJC/0JCQv9tbW3/6enp/5GRkf9DQ0P/QkJC/0JC
Qv9CQkL/jo6O//Ly8v/29vb/9vb2//b29v9vb2//QkJC/0JCQv+ysrL/9vb2//b29v/19fWAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/WVlZ/0JC
Qv9CQkL/ycnJ//b29v/29vb/9vb2//b29v/29vb/9vb2//X19f+IiIj/QkJC/0JCQv9CQkL/bW1t/+3t
7f/29vb/9vb2/8HBwf9OTk7/QkJC/0JCQv9CQkL/b29v/+vr6//29vb/9vb2/29vb/9CQkL/QkJC/7Ky
sv/29vb/9vb2//X19YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v+tra3/oqKi/6Kiov/h4eH/9vb2//b29v/29vb/9vb2//b29v/19fX/iIiI/0JC
Qv9CQkL/QkJC/21tbf/t7e3/9vb2//b29v/29vb/9vb2/9zc3P9hYWH/QkJC/0JCQv9CQkL/YGBg/+Hh
4f/29vb/b29v/0JCQv9CQkL/srKy//b29v/29vb/9fX1gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9fX1/4iIiP9CQkL/QkJC/0JCQv9tbW3/7e3t//b29v/29vb/9vb2//b29v/29vb/9vb2/+3t
7f99fX3/QkJC/0JCQv9CQkL/VFRU/9XV1f9vb2//QkJC/0JCQv+ysrL/9vb2//b29v/19fWAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//X19f+IiIj/QkJC/0JCQv9CQkL/bW1t/+3t7f/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//X19f+UlJT/QkJC/0JCQv9CQkL/TExM/1VVVf9CQkL/QkJC/7Ky
sv/29vb/9vb2//X19YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD39/eZ9/f3mff395n39/eZ9/f3mfX19bv29vb/9vb2//b29v/19fX/jY2N/0JCQv9CQkL/QkJC/2xs
bP/s7Oz/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v+oqKj/Q0ND/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/srKy//b29v/29vb/9fX1gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4+Pgk9vb24/b29v/29vb/9fX1/42N
jf9CQkL/QkJC/0JCQv9sbGz/7Ozs//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v+2trb/RkZG/0JCQv9CQkL/QkJC/0JCQv+ysrL/9vb2//b29v/19fWfAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9/f3Ifb2
9uP29vb/9vb2//X19f+IiIj/QkJC/0JCQv9CQkL/bm5u/+3t7f/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/AwMD/RkZG/0JCQv9CQkL/QkJC/6ur
q//29vb/9vb2//b29v/19fVqAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAPf39x729vbd9vb2//b29v/19fX/jY2N/0JCQv9CQkL/QkJC/2xsbP/s7Oz/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v+8vLz/RUVF/0JCQv9CQkL/TExM/9fX1//29vb/9vb2//b29v329vZSAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD39/cg9vb23/b29v/29vb/9fX1/42Njf9CQkL/QkJC/0JC
Qv9ubm7/7e3t//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v+xsbH/Q0ND/0JCQv9CQkL/WVlZ/+bm5v/29vb/9vb2//b2
9vb29vY2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8fHxEvb29uD29vb/9vb2//X1
9f+Ojo7/QkJC/0JCQv9CQkL/TExM/+zs7P/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v+pqan/QkJC/0JC
Qv9CQkL/aGho//Hx8f/29vb/9vb2//b29t7v7+8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vZ09vb2//b29v/19fX/jo6O/0JCQv9CQkL/QkJC/0JCQv9CQkL/m5ub//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v+cnJz/QkJC/0JCQv9CQkL/ioqK//b29v/29vb/9vb2//b29qwAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29tz29vb/9vb2/6Wlpf9CQkL/QkJC/0JCQv9KSkr/QkJC/0JC
Qv9ERET/ysrK//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//X19f99fX3/QkJC/0JCQv9CQkL/s7Oz//b2
9v/29vb/9vb2//X19WgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/YGBg/0JC
Qv9CQkL/ampq/+Pj4/9fX1//QkJC/0JCQv9TU1P/4+Pj//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2/+7u
7v9jY2P/QkJC/0JCQv9JSUn/2dnZ//b29v/29vb/9vb28vb29hsAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ZWVn/QkJC/0JCQv/IyMj/9vb2/9fX1/9JSUn/QkJC/0JCQv9oaGj/8PDw//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2/9ra2v9LS0v/QkJC/0JCQv9qamr/9PT0//b29v/29vb/9vb2pgAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/8nJyf/29vb/9vb2/7Oz
s/9CQkL/QkJC/0JCQv+Ghob/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/8/Pz/6Wlpf9kZGT/RkZG/1ZWVv+QkJD/5OTk//b29v/29vb/9vb2/7Ozs/9CQkL/QkJC/0JC
Qv+jo6P/9vb2//b29v/29vb/9/f3QgAAAAAAAAAAAAAAAAAAAAAAAAAA9vb2//b29v/29vb/WVlZ/0JC
Qv9CQkL/ycnJ//b29v/29vb/9vb2/4iIiP9CQkL/QkJC/0JCQv+dnZ3/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//Pz8/95eXn/QkJC/0JCQv9CQkL/QkJC/0JCQv9WVlb/2tra//b2
9v/29vb/9vb2/319ff9CQkL/QkJC/0hISP/c3Nz/9vb2//b29v/29vbQ////AQAAAAAAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ZWVn/QkJC/0JCQv/Jycn/9vb2//b29v/29vb/8PDw/2xsbP9CQkL/QkJC/0JC
Qv+vr6//9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/o6Oj/0JCQv9CQkL/QkJC/0JC
Qv9CQkL/QkJC/0JCQv9ra2v/9fX1//b29v/29vb/5eXl/05OTv9CQkL/QkJC/3x8fP/29vb/9vb2//b2
9v/19fVPAAAAAAAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/8nJyf/29vb/9vb2//b2
9v/29vb/6enp/19fX/9CQkL/QkJC/0RERP++vr7/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v9ZWVn/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv/T09P/9vb2//b29v/29vb/sLCw/0JC
Qv9CQkL/QkJC/8vLy//29vb/9vb2//b29s7///8BAAAAAAAAAAAAAAAA9vb2//b29v/29vb/WVlZ/0JC
Qv9CQkL/ycnJ//b29v/29vb/9vb2//b29v/29vb/39/f/1RUVP9CQkL/QkJC/0hISP/IyMj/9vb2//b2
9v/29vb/9vb2//b29v/29vb/8PDw/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/7e3
t//29vb/9vb2//b29v/19fX/ZGRk/0JCQv9CQkL/Z2dn//X19f/29vb/9vb2//X19UoAAAAAAAAAAAAA
AAD29vb/9vb2//b29v9ZWVn/QkJC/0JCQv/Jycn/9vb2//b29v/29vb/9vb2//b29v/29vb/1NTU/0xM
TP9CQkL/QkJC/0lJSf/BwcH/9vb2//b29v/29vb/9vb2//b29v/19fX/R0dH/0JCQv9CQkL/QkJC/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/wsLC//b29v/29vb/9vb2//b29v/FxcX/QkJC/0JCQv9CQkL/xMTE//b2
9v/29vb/9vb2sQAAAAAAAAAAAAAAAPb29v/29vb/9vb2/1lZWf9CQkL/QkJC/2ZmZv9ycnL/cnJy/3Jy
cv9ycnL/cnJy/3Jycv9ycnL/VlZW/0JCQv9CQkL/QkJC/0ZGRv+5ubn/9vb2//b29v/29vb/9vb2//b2
9v94eHj/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/01NTf/u7u7/9vb2//b29v/29vb/9vb2//b2
9v9ycnL/QkJC/0JCQv90dHT/9vb2//b29v/29vb79PT0GAAAAAAAAAAA9vb2//b29v/29vb/XFxc/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0RE
RP+wsLD/9vb2//b29v/29vb/9vb2/9fX1/9KSkr/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/rq6u//b2
9v/29vb/9vb2//b29v/29vb/9vb2/8TExP9CQkL/QkJC/0NDQ//Y2Nj/9vb2//b29v/29vZzAAAAAAAA
AAD29vb/9vb2//b29v/MzMz/S0tL/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JC
Qv9CQkL/QkJC/0JCQv9CQkL/QkJC/0NDQ/+goKD/9vb2//b29v/29vb/9vb2/8zMzP9YWFj/QkJC/0JC
Qv9CQkL/S0tL/6urq//29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9fX1/2BgYP9CQkL/QkJC/42N
jf/29vb/9vb2//b29tAAAAAAAAAAAPf395n29vb/9vb2//b29v/T09P/rq6u/66urv+urq7/rq6u/66u
rv+urq7/rq6u/66urv+urq7/rq6u/66urv+urq7/p6en/01NTf9CQkL/QkJC/0JCQv+Dg4P/8PDw//b2
9v/29vb/9vb2//Ly8v/ExMT/rq6u/7u7u//q6ur/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/q6ur/0JCQv9CQkL/U1NT//X19f/29vb/9vb2/vb29hsAAAAA////Afb29pX29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/2NjY/1dX
V/9CQkL/QkJC/0JCQv9nZ2f/39/f//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/n5+f/RUVF/0JCQv9CQkL/zMzM//b29v/29vb/9/f3YQAA
AAAAAAAA////Afb29pX29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/5OTk/2VlZf9CQkL/QkJC/0JCQv9MTEz/ubm5//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v90dHT/QkJC/0JC
Qv+RkZH/9vb2//b29v/29vaoAAAAAAAAAAAAAAAA////AfX19Wb29vZ39vb2d/b29nf29vZ39vb2d/b2
9nf29vZ39vb2d/b29nf29vZ39vb2d/b29nf19fWc9vb2//b29v/29vb/7+/v/4eHh/9CQkL/QkJC/0JC
Qv9CQkL/goKC/+jo6P/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2/66urv9CQkL/QkJC/19fX//29vb/9vb2//b29uoAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD29vZs9vb2/fb2
9v/29vb/9vb2/7CwsP9ISEj/QkJC/0JCQv9CQkL/UVFR/7a2tv/19fX/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/2dnZ/0JCQv9CQkL/Q0ND/+zs7P/29vb/9vb2//X1
9RoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAD29vZR9vb29vb29v/29vb/9vb2/9jY2P9nZ2f/QkJC/0JCQv9CQkL/QkJC/2dn
Z//Jycn/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/19fX/Tk5O/0JC
Qv9CQkL/zMzM//b29v/29vb/9PT0SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD29vY59vb24fb29v/29vb/9vb2//Pz
8/+fn5//RkZG/0JCQv9CQkL/QkJC/0JCQv91dXX/yMjI//b29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29v90dHT/QkJC/0JCQv+oqKj/9vb2//b29v/29vZ3AAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AADx8fES9vb2sPb29v/29vb/9vb2//b29v/Z2dn/d3d3/0JCQv9CQkL/QkJC/0JCQv9CQkL/ZWVl/7Gx
sf/r6+v/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2/42Njf9CQkL/QkJC/42Njf/29vb/9vb2//X1
9Z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8B9vb2bfb29vT29vb/9vb2//b29v/29vb/x8fH/2Rk
ZP9CQkL/QkJC/0JCQv9CQkL/QkJC/0dHR/96enr/s7Oz/97e3v/29vb/9vb2//b29v/29vb/oqKi/0JC
Qv9CQkL/gICA//b29v/29vb/9/f3twAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9fX1Gvb2
9qL29vb/9vb2//b29v/29vb/9fX1/8HBwf9wcHD/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/1RU
VP97e3v/kZGR/6ioqP+NjY3/QkJC/0JCQv90dHT/9vb2//b29v/29vbNAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAPT09Ej19fXW9vb2//b29v/29vb/9vb2//b29v/U1NT/iIiI/05O
Tv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/2hoaP/29vb/9vb2//b2
9uUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///wL29vZV9vb20fb2
9v/29vb/9vb2//b29v/29vb/8/Pz/8XFxf+Kior/WVlZ/0JCQv9CQkL/QkJC/0JCQv9CQkL/QkJC/0JC
Qv9CQkL/ioqK//b29v/29vb/9vb29gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAD///8B9fX1Tvf397b29vb89vb2//b29v/29vb/9vb2//b29v/29vb/6enp/8TE
xP+goKD/hYWF/3p6ev9ubm7/YWFh/4iIiP/09PT/9vb2//b29v/29vbkAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPb29hz39/d69vb22Pb2
9v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb2//b29v/29vb/9vb26Pn5
+SoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAA+Pj4IvX19Wn29vaw9fX18fb29v/29vb/9vb2//b29v/29vb/9vb2//b2
9v/29vb/9vb2//b29uf5+fkpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8B+Pj4Ivb2
9lL19fWA9vb2qPb29sH19fXW9vb27fb29v/29vbn+fn5KQAAAAAAAAAAAADwAA//wAAAAOAAB//AAAAA
wAAD/8AAAACAAAP/wAAAAAAAA//AAAAAAAAD/8AAAAAAAAP/wAAAAAAAA//AAAAAAAAD/8AAAAAAAAP/
wAAAAAAAA//AAAAAAAAD/8AAAAAAAAP/wAAAAAAAA//AAAAAAAAD/8AAAAAAAAP/wADwAAAAA//AAOAA
AAAB/8AAwAAAAAD/wACAAAAAAH/AAAAAAAAAP8AAAAAAAAA/wAAAAAAAAB/AAAAAAAAAD8AAAAAAAAAP
wAAAAAAAAAfAAAAAAAAAA8AAAAAAAAADwAAAAAAAAAHAAAAAAAAAAcAAAAAAAAABwAAAAAAAAADAAAAA
AAAAAMAAAAAAAAAAwAAAAAAAAABAAAAAAAAAAEAAgAAAAAAAQADAAAAAAABAAP//gAAAAAAA///AAAAA
AAD//+AAAAAAAP//8AAAAAAA///4AAAAAAD///4AAAAAAP///4AAAAAA////wAAAAAD////wAAAAAP//
//4AAAAA/////8AAQAD/////+ADAAA==
</value>
</data>
</root>

View File

@@ -0,0 +1,24 @@
// <copyright file="Program.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.Windows.Forms;
/// <summary>
/// The static main program.
/// </summary>
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
internal static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
}

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ClientLauncher")]

View File

@@ -0,0 +1,103 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace MUnique.OpenMU.ClientLauncher.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MUnique.OpenMU.ClientLauncher.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap Add_16x {
get {
object obj = ResourceManager.GetObject("Add_16x", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap Edit_16x {
get {
object obj = ResourceManager.GetObject("Edit_16x", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap Remove_16x {
get {
object obj = ResourceManager.GetObject("Remove_16x", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap Settings_16x {
get {
object obj = ResourceManager.GetObject("Settings_16x", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}

View File

@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="Add_16x" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Add.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="Edit_16x" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Edit.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="Remove_16x" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Remove.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="Settings_16x" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\Settings_16x.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,90 @@
// <copyright file="ServerHostSettings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ClientLauncher;
using System.ComponentModel;
using System.Runtime.CompilerServices;
/// <summary>
/// Settings for one server.
/// </summary>
public class ServerHostSettings : INotifyPropertyChanged
{
private string? _description;
private string? _address;
private int _port;
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Gets or sets the name of the configuration.
/// </summary>
public string? Description
{
get => this._description;
set
{
if (value == this._description)
{
return;
}
this._description = value;
this.RaisePropertyChanged();
}
}
/// <summary>
/// Gets or sets the host ip.
/// </summary>
public string? Address
{
get => this._address;
set
{
if (value == this._address)
{
return;
}
this._address = value;
this.RaisePropertyChanged();
}
}
/// <summary>
/// Gets or sets the host port.
/// </summary>
public int Port
{
get => this._port;
set
{
if (value == this._port)
{
return;
}
this._port = value;
this.RaisePropertyChanged();
}
}
/// <inheritdoc />
public override string ToString()
{
return $"{this.Description} ({this.Address}:{this.Port})";
}
/// <summary>
/// Raises the <see cref="PropertyChanged"/> event for the specified property.
/// </summary>
/// <param name="propertyName">The name of the property.</param>
protected virtual void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="CheckMaximumConnectionsPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// Plugin which checks if the maximum number of connections got exceeded. Refuses the connection to new clients, if that happens.
/// </summary>
internal class CheckMaximumConnectionsPlugin : IAfterSocketAcceptPlugin
{
private readonly ILogger<CheckMaximumConnectionsPlugin> _logger;
private readonly ClientListener _clientListener;
private readonly IConnectServerSettings _connectServerSettings;
/// <summary>
/// Initializes a new instance of the <see cref="CheckMaximumConnectionsPlugin" /> class.
/// </summary>
/// <param name="server">The server.</param>
/// <param name="logger">The logger.</param>
public CheckMaximumConnectionsPlugin(ConnectServer server, ILogger<CheckMaximumConnectionsPlugin> logger)
{
this._logger = logger;
this._clientListener = server.ClientListener;
this._connectServerSettings = server.Settings;
}
/// <inheritdoc/>
public bool OnAfterSocketAccept(Socket socket)
{
var maxConnections = this._connectServerSettings.MaxConnections;
if (maxConnections <= this._clientListener.Clients.Count)
{
this._logger.LogWarning("Connection refused from {0}: maximum connections ({1}) reached.", socket.RemoteEndPoint, maxConnections);
return false;
}
return true;
}
}

138
src/ConnectServer/Client.cs Normal file
View File

@@ -0,0 +1,138 @@
// <copyright file="Client.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Buffers;
using System.Net;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.ConnectServer.PacketHandler;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets.ConnectServer;
using Nito.AsyncEx.Synchronous;
/// <summary>
/// The client which connected to the connect server.
/// </summary>
internal sealed class Client : IDisposable
{
private readonly ILogger<Client> _logger;
private readonly byte[] _receiveBuffer;
private readonly Timer _onlineTimer;
private readonly IPacketHandler<Client> _packetHandler;
private bool _disposed;
private DateTime _lastReceive;
/// <summary>
/// Initializes a new instance of the <see cref="Client" /> class.
/// </summary>
/// <param name="connection">The connection.</param>
/// <param name="timeout">The timeout.</param>
/// <param name="packetHandler">The packet handler.</param>
/// <param name="maxPacketSize">Maximum size of the packet. This value is also used to initialize the receive buffer.</param>
/// <param name="logger">The logger.</param>
public Client(IConnection connection, TimeSpan timeout, IPacketHandler<Client> packetHandler, byte maxPacketSize, ILogger<Client> logger)
{
this.Connection = connection;
this.Connection.PacketReceived += this.OnPacketReceivedAsync;
this.Timeout = timeout;
this._packetHandler = packetHandler;
this._logger = logger;
this._lastReceive = DateTime.Now;
var checkInterval = new TimeSpan(0, 0, 20);
this._onlineTimer = new Timer(this.OnOnlineTimerElapsed, null, checkInterval, checkInterval);
this._receiveBuffer = new byte[maxPacketSize];
}
/// <summary>
/// Gets or sets the timeout after which the client gets disconnected if he is inactive.
/// </summary>
public TimeSpan Timeout { get; set; }
/// <summary>
/// Gets or sets the server information request count.
/// </summary>
/// <remarks>Used for DOS protection.</remarks>
public int ServerInfoRequestCount { get; set; }
/// <summary>
/// Gets or sets the FTP request count.
/// </summary>
/// <remarks>Used for DOS protection.</remarks>
public int FtpRequestCount { get; set; }
/// <summary>
/// Gets or sets the server list request count.
/// </summary>
/// <remarks>Used for DOS protection.</remarks>
public int ServerListRequestCount { get; set; }
/// <summary>
/// Gets or sets the ip from which the client is connecting.
/// </summary>
public IPAddress Address { get; set; } = IPAddress.None;
/// <summary>
/// Gets or sets the port from which the client is connecting.
/// </summary>
public int Port { get; set; }
/// <summary>
/// Gets the connection from/to the client.
/// </summary>
internal IConnection Connection { get; }
/// <inheritdoc/>
public void Dispose()
{
if (!this._disposed)
{
this._disposed = true;
this._onlineTimer.Dispose();
this.Connection.Dispose();
}
}
/// <summary>
/// Sends the hello packet.
/// </summary>
internal ValueTask SendHelloAsync()
{
return this.Connection.SendHelloAsync();
}
private void OnOnlineTimerElapsed(object? state)
{
try
{
if (this.Connection.Connected && DateTime.Now.Subtract(this._lastReceive) > this.Timeout)
{
this._logger.LogDebug("Connection Timeout ({0}): Address {1}:{2} will be disconnected.", this.Timeout, this.Address, this.Port);
this.Connection.DisconnectAsync().AsTask().WaitAndUnwrapException();
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when disconnecting client. Address {1}:{2}", this.Address, this.Port);
}
}
private async ValueTask OnPacketReceivedAsync(ReadOnlySequence<byte> sequence)
{
this._lastReceive = DateTime.Now;
if (sequence.Length > this._receiveBuffer.Length)
{
this._logger.LogInformation($"Client {this.Address}:{this.Port} will be disconnected because it sent a packet which was too big (size of {sequence.Length}");
await this.Connection.DisconnectAsync().ConfigureAwait(false);
}
sequence.CopyTo(this._receiveBuffer);
await this._packetHandler
.HandlePacketAsync(this, this._receiveBuffer.AsMemory(0, this._receiveBuffer.GetPacketSize()))
.ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,60 @@
// <copyright file="ClientConnectionCountPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// The client connection count plugin.
/// </summary>
internal class ClientConnectionCountPlugin : IAfterSocketAcceptPlugin, IAfterDisconnectPlugin
{
private readonly ILogger<ClientConnectionCountPlugin> _logger;
private readonly ClientConnectionCounter _clientCounter;
private readonly IConnectServerSettings _connectServerSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ClientConnectionCountPlugin" /> class.
/// </summary>
/// <param name="connectServerSettings">The settings.</param>
/// <param name="logger">The logger.</param>
public ClientConnectionCountPlugin(IConnectServerSettings connectServerSettings, ILogger<ClientConnectionCountPlugin> logger)
{
this._connectServerSettings = connectServerSettings;
this._logger = logger;
this._clientCounter = new ClientConnectionCounter();
}
/// <inheritdoc/>
public bool OnAfterSocketAccept(Socket socket)
{
var ipAddress = (socket.RemoteEndPoint as IPEndPoint)?.Address;
if (ipAddress is null)
{
// should never happen - but who knows. In this case, we allow the connection.
this._logger.LogDebug($"Non-IPEndPoint connected: {socket.RemoteEndPoint}.");
return true;
}
if (this._connectServerSettings.CheckMaxConnectionsPerAddress
&& this._clientCounter.GetConnectionCount(ipAddress) >= this._connectServerSettings.MaxConnectionsPerAddress)
{
this._logger.LogWarning("Maximum Connections per IP reached: {0}, Connection refused.", ipAddress);
return false;
}
this._clientCounter.AddConnection(ipAddress);
return true;
}
/// <inheritdoc/>
public void OnAfterDisconnect(Client client)
{
this._clientCounter.RemoveConnection(client.Address);
}
}

View File

@@ -0,0 +1,72 @@
// <copyright file="ClientConnectionCounter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net;
/// <summary>
/// Counts the connections per ip address.
/// </summary>
internal class ClientConnectionCounter
{
private readonly IDictionary<IPAddress, int> _connections = new Dictionary<IPAddress, int>();
private readonly object _syncRoot = new();
/// <summary>
/// Gets the connection count of the specified ip address.
/// </summary>
/// <param name="ipAddress">The ip address.</param>
/// <returns>The counted connections of the ip address.</returns>
public int GetConnectionCount(IPAddress ipAddress)
{
int count;
lock (this._syncRoot)
{
this._connections.TryGetValue(ipAddress, out count);
}
return count;
}
/// <summary>
/// Adds the connection and increases its count for the specified ip address.
/// </summary>
/// <param name="ipAddress">The ip address.</param>
public void AddConnection(IPAddress ipAddress)
{
lock (this._syncRoot)
{
if (this._connections.ContainsKey(ipAddress))
{
this._connections[ipAddress]++;
}
else
{
this._connections.Add(ipAddress, 1);
}
}
}
/// <summary>
/// Removes the connection and decreases its count for the specified ip address.
/// </summary>
/// <param name="ipAddress">The ip address.</param>
public void RemoveConnection(IPAddress ipAddress)
{
lock (this._syncRoot)
{
if (!this._connections.ContainsKey(ipAddress))
{
return;
}
this._connections[ipAddress]--;
if (this._connections[ipAddress] == 0)
{
this._connections.Remove(ipAddress);
}
}
}
}

View File

@@ -0,0 +1,133 @@
// <copyright file="ClientListener.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.ConnectServer.PacketHandler;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using Nito.AsyncEx;
/// <summary>
/// The listener which is waiting for new connecting clients.
/// </summary>
internal class ClientListener
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<ClientListener> _logger;
private readonly AsyncLock _clientListLock = new();
private readonly IConnectServerSettings _connectServerSettings;
private readonly IPacketHandler<Client> _packetHandler;
private Listener? _listener;
/// <summary>
/// Initializes a new instance of the <see cref="ClientListener" /> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ClientListener(IConnectServer connectServer, ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
this._connectServerSettings = connectServer.Settings;
this._logger = this._loggerFactory.CreateLogger<ClientListener>();
this._packetHandler = new ClientPacketHandler(connectServer, loggerFactory);
this.Clients = new List<Client>();
this.ClientSocketAcceptPlugins = new List<IAfterSocketAcceptPlugin>();
this.ClientSocketDisconnectPlugins = new List<IAfterDisconnectPlugin>();
}
/// <summary>
/// Occurs when the number of connected clients changed.
/// </summary>
public event EventHandler? ConnectedClientsChanged;
/// <summary>
/// Gets the connected clients.
/// </summary>
public ICollection<Client> Clients { get; }
/// <summary>
/// Gets the client socket accept plugins.
/// </summary>
public IList<IAfterSocketAcceptPlugin> ClientSocketAcceptPlugins { get; }
/// <summary>
/// Gets the client socket disconnect plugins.
/// </summary>
public ICollection<IAfterDisconnectPlugin> ClientSocketDisconnectPlugins { get; }
/// <summary>
/// Starts the listener.
/// </summary>
public void StartListener()
{
this._listener = new Listener(this._connectServerSettings.ClientListenerPort, null, null, this._loggerFactory);
this._listener.ClientAccepting += this.OnClientAcceptingAsync;
this._listener.ClientAccepted += this.OnClientAcceptedAsync;
this._listener.Start(this._connectServerSettings.ListenerBacklog);
this._logger.LogInformation("Client Listener started, Port {0}", this._connectServerSettings.ClientListenerPort);
}
/// <summary>
/// Stops the listener.
/// </summary>
public void StopListener()
{
this._listener?.Stop();
this._logger.LogInformation("Client Listener stopped");
}
private async ValueTask OnClientAcceptingAsync(ClientAcceptingEventArgs e)
{
for (var i = 0; i < this.ClientSocketAcceptPlugins.Count; ++i)
{
var plugin = this.ClientSocketAcceptPlugins[i];
if (!plugin.OnAfterSocketAccept(e.AcceptingSocket))
{
e.Cancel = true;
break;
}
}
}
private async ValueTask OnClientAcceptedAsync(ClientAcceptedEventArgs e)
{
var connection = e.AcceptedConnection;
var client = new Client(connection, this._connectServerSettings.Timeout, this._packetHandler, this._connectServerSettings.MaximumReceiveSize, this._loggerFactory.CreateLogger<Client>());
var ipEndpoint = connection.EndPoint as IPEndPoint;
client.Address = ipEndpoint?.Address ?? IPAddress.None;
client.Port = ipEndpoint?.Port ?? 0;
client.Timeout = this._connectServerSettings.Timeout;
using (await this._clientListLock.LockAsync().ConfigureAwait(false))
{
this.Clients.Add(client);
}
client.Connection.Disconnected += async () => await this.OnClientDisconnectAsync(client).ConfigureAwait(false);
this._logger.LogDebug("Client connected: {0}, current client count: {1}", connection.EndPoint, this.Clients.Count);
await client.SendHelloAsync().ConfigureAwait(false);
_ = Task.Run(() => client.Connection.BeginReceiveAsync());
this.ConnectedClientsChanged?.Invoke(this, EventArgs.Empty);
}
private async ValueTask OnClientDisconnectAsync(Client client)
{
foreach (var plugin in this.ClientSocketDisconnectPlugins)
{
plugin.OnAfterDisconnect(client);
}
this._logger.LogDebug("Connection to Client {0}:{1} disconnected.", client.Address, client.Port);
using (await this._clientListLock.LockAsync().ConfigureAwait(false))
{
this.Clients.Remove(client);
}
this.ConnectedClientsChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -0,0 +1,242 @@
// <copyright file="ConnectServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network.PlugIns;
/// <summary>
/// The connect server.
/// </summary>
public class ConnectServer : IConnectServer, OpenMU.Interfaces.IConnectServer
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly ServerList _serverList;
private ServerState _serverState;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectServer" /> class.
/// </summary>
/// <param name="connectServerSettings">The settings.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ConnectServer(IConnectServerSettings connectServerSettings, ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
this.Settings = connectServerSettings;
this.ClientVersion = new ClientVersion(this.Settings.Client.Season, this.Settings.Client.Episode, ClientLanguage.Invariant);
this.ConfigurationId = this.Settings.ConfigurationId;
this._logger = this._loggerFactory.CreateLogger<ConnectServer>();
this.ConnectInfos = new ConcurrentDictionary<ushort, byte[]>();
this._serverList = new ServerList(this.ClientVersion);
this.ClientListener = new ClientListener(this, loggerFactory);
this.ClientListener.ConnectedClientsChanged += (_, _) =>
{
this.RaisePropertyChanged(nameof(this.CurrentConnections));
};
this.CreatePlugins();
}
/// <inheritdoc />
public event PropertyChangedEventHandler? PropertyChanged;
/// <inheritdoc/>
public ServerState ServerState
{
get => this._serverState;
private set
{
if (value != this._serverState)
{
this._serverState = value;
this.RaisePropertyChanged();
}
}
}
/// <inheritdoc />
public ServerType Type => ServerType.ConnectServer;
/// <inheritdoc/>
public string Description => this.Settings.Description;
/// <inheritdoc/>
public int Id => SpecialServerIds.ConnectServer + this.Settings.ServerId;
/// <inheritdoc />
public Guid ConfigurationId { get; }
/// <inheritdoc/>
public ConcurrentDictionary<ushort, byte[]> ConnectInfos { get; }
/// <inheritdoc/>
ServerList IConnectServer.ServerList => this._serverList;
/// <inheritdoc cref="IConnectServer"/>
public IConnectServerSettings Settings { get; }
/// <inheritdoc />
public ClientVersion ClientVersion { get; }
/// <summary>
/// Gets the maximum allowed connections.
/// </summary>
public int MaximumConnections => this.Settings.MaxConnections;
/// <summary>
/// Gets the current connection count.
/// </summary>
public int CurrentConnections => this.ClientListener.Clients.Count;
/// <summary>
/// Gets the current game server connection count.
/// </summary>
public int CurrentGameServerConnections => this._serverList.TotalConnectionCount;
/// <summary>
/// Gets the registered game servers.
/// </summary>
public IEnumerable<IGameServerEntry> RegisteredGameServers => this._serverList.Items;
/// <summary>
/// Gets the client listener.
/// </summary>
internal ClientListener ClientListener { get; }
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
await this.StartAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask StartAsync()
{
if (this.ServerState != ServerState.Stopped)
{
return;
}
this._logger.LogInformation("Begin starting");
var oldState = this.ServerState;
this.ServerState = OpenMU.Interfaces.ServerState.Starting;
try
{
this.ClientListener.StartListener();
this.ServerState = OpenMU.Interfaces.ServerState.Started;
}
catch (Exception ex)
{
this._logger.LogError(ex, ex.Message);
this.ServerState = oldState;
}
this._logger.LogInformation("Finished starting");
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
await this.ShutdownAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask ShutdownAsync()
{
this._logger.LogInformation("Begin stopping");
this.ServerState = OpenMU.Interfaces.ServerState.Stopping;
this.ClientListener.StopListener();
this.ServerState = OpenMU.Interfaces.ServerState.Stopped;
this._logger.LogInformation("Finished stopping");
}
/// <inheritdoc/>
public void RegisterGameServer(ServerInfo gameServer, IPEndPoint publicEndPoint)
{
this._logger.LogInformation("GameServer {0} is registering with endpoint {1}", gameServer, publicEndPoint);
try
{
if (this.ConnectInfos.ContainsKey(gameServer.Id))
{
this._logger.LogInformation("GameServer {0} was already registered and needs to be removed before...", gameServer);
this.UnregisterGameServer(gameServer.Id);
}
var serverListItem = new ServerListItem(this._serverList)
{
ServerId = gameServer.Id,
EndPoint = publicEndPoint,
MaximumConnections = gameServer.MaximumConnections,
CurrentConnections = gameServer.CurrentConnections,
};
if (this.ConnectInfos.TryAdd(serverListItem.ServerId, serverListItem.ConnectInfo))
{
this._serverList.Add(serverListItem);
}
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error during registration process");
throw;
}
this._logger.LogInformation("GameServer {0} has registered with endpoint {1}", gameServer, publicEndPoint);
}
/// <inheritdoc/>
public void UnregisterGameServer(ushort gameServerId)
{
this._logger.LogInformation("GameServer {0} is unregistering", gameServerId);
var serverListItem = this._serverList.GetItem(gameServerId);
if (serverListItem != null)
{
this.ConnectInfos.Remove(serverListItem.ServerId, out _);
this._serverList.Remove(serverListItem);
}
this._logger.LogInformation("GameServer {0} has unregistered", gameServerId);
}
/// <inheritdoc />
public void CurrentConnectionsChanged(ushort serverId, int currentConnections)
{
var serverListItem = this._serverList.GetItem(serverId);
if (serverListItem is null)
{
return;
}
serverListItem.CurrentConnections = currentConnections;
}
private void CreatePlugins()
{
this._logger.LogDebug("Begin creating plugins");
this.ClientListener.ClientSocketAcceptPlugins.Add(new CheckMaximumConnectionsPlugin(this, this._loggerFactory.CreateLogger<CheckMaximumConnectionsPlugin>()));
var clientCountPlugin = new ClientConnectionCountPlugin(this.Settings, this._loggerFactory.CreateLogger<ClientConnectionCountPlugin>());
this.ClientListener.ClientSocketAcceptPlugins.Add(clientCountPlugin);
this.ClientListener.ClientSocketDisconnectPlugins.Add(clientCountPlugin);
this._logger.LogDebug("Finished creating plugins");
}
/// <summary>
/// Called when a property changed.
/// </summary>
/// <param name="propertyName">Name of the property.</param>
private void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,37 @@
// <copyright file="ConnectServerFactory.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// The connect server factory.
/// </summary>
public class ConnectServerFactory
{
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectServerFactory"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public ConnectServerFactory(ILoggerFactory loggerFactory)
{
this._loggerFactory = loggerFactory;
}
/// <summary>
/// Creates a new connect server instance.
/// </summary>
/// <param name="settings">The settings.</param>
/// <returns>
/// The new connect server instance.
/// </returns>
public OpenMU.Interfaces.IConnectServer CreateConnectServer(IConnectServerSettings settings)
{
return new ConnectServer(settings, this._loggerFactory);
}
}

View File

@@ -0,0 +1,17 @@
// <copyright file="IAfterDisconnectPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
/// <summary>
/// Plugin which is executed after a client disconnected.
/// </summary>
internal interface IAfterDisconnectPlugin
{
/// <summary>
/// Called after a client disconnected.
/// </summary>
/// <param name="client">The client.</param>
void OnAfterDisconnect(Client client);
}

View File

@@ -0,0 +1,20 @@
// <copyright file="IAfterSocketAcceptPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net.Sockets;
/// <summary>
/// Plugin which is executed when a client socket got accepted by the listener.
/// </summary>
internal interface IAfterSocketAcceptPlugin
{
/// <summary>
/// Called after the client socket got accepted by the listener.
/// </summary>
/// <param name="socket">The socket.</param>
/// <returns>Flag that indicates if the socket is allowed to connect.</returns>
bool OnAfterSocketAccept(Socket socket);
}

View File

@@ -0,0 +1,35 @@
// <copyright file="IConnectServer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Collections.Concurrent;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network.PlugIns;
/// <summary>
/// The internal interface of a connect server.
/// </summary>
internal interface IConnectServer
{
/// <summary>
/// Gets the connect infos.
/// </summary>
ConcurrentDictionary<ushort, byte[]> ConnectInfos { get; }
/// <summary>
/// Gets the server list.
/// </summary>
ServerList ServerList { get; }
/// <summary>
/// Gets the connectServerSettings.
/// </summary>
IConnectServerSettings Settings { get; }
/// <summary>
/// Gets the client version.
/// </summary>
ClientVersion ClientVersion { get; }
}

View File

@@ -0,0 +1,33 @@
// <copyright file="IGameServerEntry.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Net;
/// <summary>
/// Interface for an entry of a gameserver in the connect server.
/// </summary>
public interface IGameServerEntry
{
/// <summary>
/// Gets the server identifier.
/// </summary>
ushort ServerId { get; }
/// <summary>
/// Gets the end point under which the server is accessible.
/// </summary>
IPEndPoint EndPoint { get; }
/// <summary>
/// Gets the server load percentage.
/// </summary>
byte ServerLoadPercentage { get; }
/// <summary>
/// Gets the count of current connections.
/// </summary>
int CurrentConnections { get; }
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\bin\Debug\MUnique.OpenMU.ConnectServer.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\bin\Release\MUnique.OpenMU.ConnectServer.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
<ProjectReference Include="..\Network\MUnique.OpenMU.Network.csproj" />
<ProjectReference Include="..\Network\Packets\MUnique.OpenMU.Network.Packets.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
// <copyright file="ClientPacketHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using IConnectServer = MUnique.OpenMU.ConnectServer.IConnectServer;
/// <summary>
/// The handler of packets coming from the client.
/// </summary>
internal class ClientPacketHandler : IPacketHandler<Client>
{
private readonly ILogger<ClientPacketHandler> _logger;
private readonly IDictionary<byte, IPacketHandler<Client>> _packetHandlers = new Dictionary<byte, IPacketHandler<Client>>();
private readonly IConnectServerSettings _connectServerSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ClientPacketHandler" /> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ClientPacketHandler(IConnectServer connectServer, ILoggerFactory loggerFactory)
{
this._logger = loggerFactory.CreateLogger<ClientPacketHandler>();
this._connectServerSettings = connectServer.Settings;
// TODO: Is 0x05 correct? PatchCheckRequest has Code 0x02
this._packetHandlers.Add(0x05, new FtpRequestHandler(connectServer.Settings, loggerFactory.CreateLogger<FtpRequestHandler>()));
this._packetHandlers.Add(0xF4, new ServerListHandler(connectServer, loggerFactory));
}
/// <inheritdoc/>
public async ValueTask HandlePacketAsync(Client client, Memory<byte> packet)
{
try
{
if (packet.Length > this._connectServerSettings.MaximumReceiveSize || packet.Length < 4)
{
await this.DisconnectClientUnknownPacketAsync(client, packet).ConfigureAwait(false);
return;
}
var packetType = packet.Span[2];
if (this._packetHandlers.TryGetValue(packetType, out var packetHandler))
{
await packetHandler.HandlePacketAsync(client, packet).ConfigureAwait(false);
}
else if (this._connectServerSettings.DisconnectOnUnknownPacket)
{
await this.DisconnectClientUnknownPacketAsync(client, packet).ConfigureAwait(false);
}
else
{
// do nothing.
}
}
catch (SocketException ex)
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
this._logger.LogDebug("SocketException occured in Client.ReceivePacket, Client Address: {0}:{1}, Packet: [{2}], Exception: {3}", client.Address, client.Port, packet.ToArray().ToHexString(), ex);
}
}
catch (Exception ex)
{
this._logger.LogWarning("Exception occured in Client.ReceivePacket, Client Address: {0}:{1}, Packet: [{2}], Exception: {3}", client.Address, client.Port, packet.ToArray().ToHexString(), ex);
}
}
private async ValueTask DisconnectClientUnknownPacketAsync(Client client, Memory<byte> packet)
{
this._logger.LogInformation("Client {0}:{1} will be disconnected because it sent an unknown packet: {2}", client.Address, client.Port, packet.ToArray().ToHexString());
await client.Connection.DisconnectAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,154 @@
// <copyright file="FtpRequestHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets.ConnectServer;
/// <summary>
/// Handles the ftp related request. The client is sending its version, and the server answers
/// with the current version and the ftp address where the client can load a patch.
/// </summary>
internal class FtpRequestHandler : IPacketHandler<Client>
{
private static readonly byte[] PatchOk = { 0xC1, 4, 2, 0 };
private static readonly byte[] Xor3Keys = { 0xFC, 0xCF, 0xAB };
private readonly IConnectServerSettings _connectServerSettings;
private readonly ILogger<FtpRequestHandler> _logger;
private byte[]? _patchPacket;
/// <summary>
/// Initializes a new instance of the <see cref="FtpRequestHandler" /> class.
/// </summary>
/// <param name="connectServerSettings">The settings.</param>
/// <param name="logger">The logger.</param>
public FtpRequestHandler(IConnectServerSettings connectServerSettings, ILogger<FtpRequestHandler> logger)
{
this._connectServerSettings = connectServerSettings;
this._logger = logger;
}
/// <summary>
/// The version compare result.
/// </summary>
private enum VersionCompareResult
{
VersionTooLow = -1,
VersionMatch = 0,
VersionHigher = 1,
}
/// <inheritdoc/>
public async ValueTask HandlePacketAsync(Client client, Memory<byte> packet)
{
if (packet.Length < 6)
{
return;
}
void LogVersion(Span<byte> span)
{
this._logger.LogDebug($"Client {client.Address}:{client.Port} version: {span[3]}.{span[4]}.{span[5]}");
}
if (this._logger.IsEnabled(LogLevel.Debug))
{
LogVersion(packet.Span);
}
if (client.FtpRequestCount >= this._connectServerSettings.MaxFtpRequests)
{
if (this._logger.IsEnabled(LogLevel.Debug))
{
this._logger.LogDebug("Client {0}:{1} reached maxFtpRequests", client.Address, client.Port);
}
await client.Connection.DisconnectAsync().ConfigureAwait(false);
return;
}
int WritePatchPacket()
{
if (this._patchPacket is { } cachedPacket)
{
var span = client.Connection.Output.GetSpan(cachedPacket.Length);
cachedPacket.CopyTo(span);
}
else
{
var length = ClientNeedsPatchRef.Length;
var span = client.Connection.Output.GetSpan(length)[..length];
var packet = new ClientNeedsPatchRef(span);
packet.PatchAddress = this._connectServerSettings.PatchAddress;
var addressSize = Encoding.UTF8.GetByteCount(this._connectServerSettings.PatchAddress);
Xor3Bytes(span.Slice(6), addressSize);
packet.PatchVersion = this._connectServerSettings.CurrentPatchVersion[2];
this._patchPacket = span.ToArray();
}
return this._patchPacket.Length;
}
int WriteOkayPacket()
{
var span = client.Connection.Output.GetSpan(PatchOk.Length)[..PatchOk.Length];
PatchOk.CopyTo(span);
return PatchOk.Length;
}
if (VersionCompare(this._connectServerSettings.CurrentPatchVersion, 0, packet.Span, 3, this._connectServerSettings.CurrentPatchVersion.Length) == VersionCompareResult.VersionTooLow)
{
await client.Connection.SendAsync(WritePatchPacket).ConfigureAwait(false);
}
else
{
await client.Connection.SendAsync(WriteOkayPacket).ConfigureAwait(false);
}
client.FtpRequestCount++;
}
/// <summary>
/// Compares the actual version of the client with the expected version.
/// </summary>
/// <param name="expectedVersion">The expected version.</param>
/// <param name="expectedIndex">The expected index.</param>
/// <param name="actualVersion">The actual version.</param>
/// <param name="actualIndex">The actual index.</param>
/// <param name="count">The count.</param>
/// <returns>The compare result.</returns>
private static VersionCompareResult VersionCompare(byte[] expectedVersion, int expectedIndex, Span<byte> actualVersion, int actualIndex, int count)
{
for (int i = 0; i < count; ++i)
{
if (expectedVersion[i + expectedIndex] > actualVersion[i + actualIndex])
{
return VersionCompareResult.VersionTooLow;
}
if (expectedVersion[i + expectedIndex] < actualVersion[i + actualIndex])
{
return VersionCompareResult.VersionHigher;
}
}
return VersionCompareResult.VersionMatch;
}
private static void Xor3Bytes(Span<byte> data, int size)
{
for (int i = 0; i < size; i++)
{
data[i] ^= Xor3Keys[i % 3];
}
}
}

View File

@@ -0,0 +1,19 @@
// <copyright file="IPacketHandler{T}.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
/// <summary>
/// The interface for a packet handler with a type which is passed as context argument.
/// </summary>
/// <typeparam name="T">Type of the context argument.</typeparam>
internal interface IPacketHandler<in T>
{
/// <summary>
/// Handles the packet.
/// </summary>
/// <param name="obj">The object.</param>
/// <param name="packet">The packet.</param>
ValueTask HandlePacketAsync(T obj, Memory<byte> packet);
}

View File

@@ -0,0 +1,115 @@
// <copyright file="ServerInfoRequestHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using System.Net;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Network;
using MUnique.OpenMU.Network.Packets.ConnectServer;
/// <summary>
/// Handles the server info request of a client, which means the client wants to know the connect data of the server it just clicked on.
/// </summary>
internal class ServerInfoRequestHandler : IPacketHandler<Client>
{
private readonly IConnectServer _connectServer;
private readonly ILogger<ServerInfoRequestHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ServerInfoRequestHandler" /> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="logger">The logger.</param>
public ServerInfoRequestHandler(IConnectServer connectServer, ILogger<ServerInfoRequestHandler> logger)
{
this._connectServer = connectServer;
this._logger = logger;
}
/// <inheritdoc/>
public async ValueTask HandlePacketAsync(Client client, Memory<byte> packet)
{
var serverId = GetServerId(packet.Span);
this._logger.LogDebug("Client {0}:{1} requested Connection Info of ServerId {2}", client.Address, client.Port, serverId);
if (client.ServerInfoRequestCount >= this._connectServer.Settings.MaxIpRequests)
{
this._logger.LogDebug($"Client {client.Address}:{client.Port} reached max ip requests.");
await client.Connection.DisconnectAsync().ConfigureAwait(false);
}
// First we look, if we can just use the IP address which the client connected to.
// If the game server is running on the same ip as the connect server, we can use that.
// This way, we can be sure, that the client can connect to it, too.
var localIpEndPoint = client.Connection.LocalEndPoint as IPEndPoint;
var serverItem = this._connectServer.ServerList.GetItem(serverId);
var isGameServerOnSameMachineAsConnectServer = (serverItem?.EndPoint.Address).IsOnSameHost();
var isClientConnectedOnNonRegisteredAddress = !object.Equals(serverItem?.EndPoint.Address, localIpEndPoint?.Address);
bool.TryParse(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), out var isRunningOnDocker);
// Only if we can't use the cached data.
if (isGameServerOnSameMachineAsConnectServer
&& !isRunningOnDocker
&& isClientConnectedOnNonRegisteredAddress)
{
int WritePacket()
{
var data = client.Connection.Output.GetSpan(ConnectionInfoRef.Length)[..ConnectionInfoRef.Length];
_ = new ConnectionInfoRef(data)
{
IpAddress = localIpEndPoint!.Address.ToString(),
Port = (ushort)serverItem!.EndPoint.Port,
};
return data.Length;
}
await client.Connection.SendAsync(WritePacket).ConfigureAwait(false);
}
else if (this._connectServer.ConnectInfos.TryGetValue(serverId, out var connectInfo))
{
// more optimal way, because the serialized data was cached.
int WritePacket()
{
var span = client.Connection.Output.GetSpan(connectInfo.Length)[..connectInfo.Length];
connectInfo.CopyTo(span);
return span.Length;
}
await client.Connection.SendAsync(WritePacket).ConfigureAwait(false);
}
else
{
this._logger.LogDebug($"Client {client.Address}:{client.Port}: Connection Info not found, sending Server List instead.");
int WritePacket()
{
var serverList = this._connectServer.ServerList.Serialize();
var span = client.Connection.Output.GetSpan(serverList.Length)[..serverList.Length];
serverList.CopyTo(span);
return span.Length;
}
await client.Connection.SendAsync(WritePacket).ConfigureAwait(false);
}
await client.SendHelloAsync().ConfigureAwait(false);
client.ServerInfoRequestCount++;
}
private static ushort GetServerId(Span<byte> packet)
{
if (packet.Length == ConnectionInfoRequestRef.Length)
{
ConnectionInfoRequestRef data = packet;
return data.ServerId;
}
if (packet.Length == ConnectionInfoRequest075Ref.Length)
{
ConnectionInfoRequest075Ref data = packet;
return data.ServerId;
}
throw new ArgumentException($"Unknown packet length C1 {packet.Length} F4 03 ...");
}
}

View File

@@ -0,0 +1,54 @@
// <copyright file="ServerListHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using IConnectServer = MUnique.OpenMU.ConnectServer.IConnectServer;
/// <summary>
/// Handles the requests of server data.
/// </summary>
internal class ServerListHandler : IPacketHandler<Client>
{
private readonly ILogger<ServerListHandler> _logger;
private readonly IConnectServerSettings _connectServerSettings;
private readonly IDictionary<byte, IPacketHandler<Client>> _packetHandlers = new Dictionary<byte, IPacketHandler<Client>>();
/// <summary>
/// Initializes a new instance of the <see cref="ServerListHandler" /> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="loggerFactory">The logger factory.</param>
public ServerListHandler(IConnectServer connectServer, ILoggerFactory loggerFactory)
{
this._logger = loggerFactory.CreateLogger<ServerListHandler>();
this._connectServerSettings = connectServer.Settings;
this._packetHandlers.Add(0x03, new ServerInfoRequestHandler(connectServer, loggerFactory.CreateLogger<ServerInfoRequestHandler>()));
this._packetHandlers.Add(0x06, new ServerListRequestHandler(connectServer, loggerFactory.CreateLogger<ServerListRequestHandler>()));
// old protocol:
this._packetHandlers.Add(0x02, new ServerListRequestHandler(connectServer, loggerFactory.CreateLogger<ServerListRequestHandler>()));
}
/// <inheritdoc/>
public async ValueTask HandlePacketAsync(Client client, Memory<byte> packet)
{
var packetSubType = packet.Span[3];
if (this._packetHandlers.TryGetValue(packetSubType, out var packetHandler))
{
await packetHandler.HandlePacketAsync(client, packet).ConfigureAwait(false);
}
else if (this._connectServerSettings.DisconnectOnUnknownPacket)
{
this._logger.LogInformation("Client {0}:{1} will be disconnected because it sent an unknown packet: {2}", client.Address, client.Port, packet.ToArray().ToHexString());
await client.Connection.DisconnectAsync().ConfigureAwait(false);
}
else
{
// do nothing
}
}
}

View File

@@ -0,0 +1,51 @@
// <copyright file="ServerListRequestHandler.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer.PacketHandler;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Network;
/// <summary>
/// Handles the request of the server list.
/// </summary>
internal class ServerListRequestHandler : IPacketHandler<Client>
{
private readonly IConnectServer _connectServer;
private readonly ILogger<ServerListRequestHandler> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ServerListRequestHandler" /> class.
/// </summary>
/// <param name="connectServer">The connect server.</param>
/// <param name="logger">The logger.</param>
public ServerListRequestHandler(IConnectServer connectServer, ILogger<ServerListRequestHandler> logger)
{
this._connectServer = connectServer;
this._logger = logger;
}
/// <inheritdoc/>
public async ValueTask HandlePacketAsync(Client client, Memory<byte> packet)
{
this._logger.LogDebug("Client {0}:{1} requested Server List", client.Address, client.Port);
if (client.ServerListRequestCount >= this._connectServer.Settings.MaxServerListRequests)
{
this._logger.LogDebug("Client {0}:{1} reached maxListRequests", client.Address, client.Port);
await client.Connection.DisconnectAsync().ConfigureAwait(false);
}
client.ServerListRequestCount++;
int WritePacket()
{
var serverList = this._connectServer.ServerList.Serialize();
var span = client.Connection.Output.GetSpan(serverList.Length)[..serverList.Length];
serverList.CopyTo(span);
return span.Length;
}
await client.Connection.SendAsync(WritePacket).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,10 @@
// <copyright file="AssemblyInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
using System.Reflection;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MUnique.OpenMU.ConnectServer")]

View File

@@ -0,0 +1,214 @@
// <copyright file="ServerList.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.ConnectServer;
using System.Threading;
using MUnique.OpenMU.Network.Packets.ConnectServer;
using MUnique.OpenMU.Network.PlugIns;
/// <summary>
/// The server list.
/// </summary>
internal class ServerList
{
private readonly ReaderWriterLockSlim _lock = new();
private readonly ICollection<ServerListItem> _servers = new SortedSet<ServerListItem>(new ServerListItemComparer());
private readonly ClientVersion _clientVersion;
/// <summary>
/// Initializes a new instance of the <see cref="ServerList" /> class.
/// </summary>
/// <param name="clientVersion">The client version.</param>
public ServerList(ClientVersion clientVersion)
{
this._clientVersion = clientVersion;
}
/// <summary>
/// Gets the total connection count.
/// </summary>
public int TotalConnectionCount
{
get
{
this._lock.EnterReadLock();
try
{
return this._servers.Sum(s => s.CurrentConnections);
}
finally
{
this._lock.ExitReadLock();
}
}
}
/// <summary>
/// Gets the cache of the available servers.
/// </summary>
public byte[]? Cache { get; private set; }
/// <summary>
/// Gets the <see cref="IGameServerEntry"/>s of this list.
/// </summary>
public IEnumerable<IGameServerEntry> Items
{
get
{
this._lock.EnterReadLock();
try
{
return this._servers.ToList();
}
finally
{
this._lock.ExitReadLock();
}
}
}
/// <summary>
/// Adds the specified item to this instance.
/// </summary>
/// <param name="item">The item.</param>
public void Add(ServerListItem item)
{
this._lock.EnterWriteLock();
try
{
this._servers.Add(item);
this.InvalidateCache();
}
finally
{
this._lock.ExitWriteLock();
}
}
/// <summary>
/// Removes the specified item from this instance.
/// </summary>
/// <param name="item">The item.</param>
public void Remove(ServerListItem item)
{
this._lock.EnterWriteLock();
try
{
this._servers.Remove(item);
this.InvalidateCache();
}
finally
{
this._lock.ExitWriteLock();
}
}
/// <summary>
/// Gets the <see cref="ServerListItem"/> of the specified server id.
/// </summary>
/// <param name="gameServerId">The game server identifier.</param>
/// <returns>The found <see cref="ServerListItem"/>.</returns>
public ServerListItem? GetItem(ushort gameServerId)
{
this._lock.EnterReadLock();
try
{
return this._servers.FirstOrDefault(s => s.ServerId == gameServerId);
}
finally
{
this._lock.ExitReadLock();
}
}
/// <summary>
/// Serializes this instance to a server list packet, which can be sent to the client.
/// </summary>
/// <returns>The serialized server list.</returns>
public byte[] Serialize()
{
var result = this.Cache;
if (result != null)
{
return result;
}
this._lock.EnterReadLock();
try
{
result = this.Cache;
if (result != null)
{
return result;
}
byte[] packet;
if (this._clientVersion.Season == 0)
{
packet = new byte[ServerListResponseOld.GetRequiredSize(this._servers.Count)];
var response = new ServerListResponseOld(packet)
{
ServerCount = (byte)this._servers.Count,
};
var i = 0;
foreach (var server in this._servers)
{
var serverBlock = response[i];
serverBlock.ServerId = (byte)server.ServerId;
serverBlock.LoadPercentage = server.ServerLoadPercentage;
i++;
}
}
else
{
packet = new byte[ServerListResponse.GetRequiredSize(this._servers.Count)];
var response = new ServerListResponse(packet)
{
ServerCount = (ushort)this._servers.Count,
};
var i = 0;
foreach (var server in this._servers)
{
var serverBlock = response[i];
serverBlock.ServerId = server.ServerId;
serverBlock.LoadPercentage = server.ServerLoadPercentage;
i++;
}
}
this.Cache = packet;
return packet;
}
finally
{
this._lock.ExitReadLock();
}
}
/// <summary>
/// Invalidates the cache.
/// </summary>
private void InvalidateCache()
{
this.Cache = null;
}
/// <summary>
/// Comparer for <see cref="ServerListItem"/>s.
/// </summary>
private class ServerListItemComparer : IComparer<ServerListItem>
{
/// <summary>Compares two objects and returns a value indicating whether one is less than, equal to, or greater than the other.</summary>
/// <returns>A signed integer that indicates the relative values of <paramref name="x" /> and <paramref name="y" />, as shown in the following table.Value Meaning Less than zero<paramref name="x" /> is less than <paramref name="y" />.Zero<paramref name="x" /> equals <paramref name="y" />.Greater than zero<paramref name="x" /> is greater than <paramref name="y" />.</returns>
/// <param name="x">The first object to compare.</param>
/// <param name="y">The second object to compare.</param>
public int Compare(ServerListItem? x, ServerListItem? y)
{
return x?.ServerId.CompareTo(y?.ServerId) ?? int.MinValue;
}
}
}

Some files were not shown because too many files have changed in this diff Show More