commit b934dedcfbf6b0bdf2bb3244eb7f058002175d37 Author: Anna Clemens Date: Fri Oct 23 17:24:32 2020 -0400 chore: initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..279b7d8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,203 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.{cs,json}] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = lf +insert_final_newline = true + +[*.cs] + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = true:error +dotnet_style_qualification_for_field = true:error +dotnet_style_qualification_for_method = true:error +dotnet_style_qualification_for_property = true:error + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:suggestion + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:error +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = false +csharp_new_line_before_else = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = none +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..99b6890 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text eol=lf +*.wav binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ee5385 --- /dev/null +++ b/.gitignore @@ -0,0 +1,362 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd diff --git a/XIVChat Desktop/App.xaml b/XIVChat Desktop/App.xaml new file mode 100644 index 0000000..ceab6dd --- /dev/null +++ b/XIVChat Desktop/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/XIVChat Desktop/App.xaml.cs b/XIVChat Desktop/App.xaml.cs new file mode 100644 index 0000000..e1eeccd --- /dev/null +++ b/XIVChat Desktop/App.xaml.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application { + } +} diff --git a/XIVChat Desktop/AssemblyInfo.cs b/XIVChat Desktop/AssemblyInfo.cs new file mode 100644 index 0000000..8b5504e --- /dev/null +++ b/XIVChat Desktop/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/XIVChat Desktop/MainWindow.xaml b/XIVChat Desktop/MainWindow.xaml new file mode 100644 index 0000000..95e683b --- /dev/null +++ b/XIVChat Desktop/MainWindow.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/XIVChat Desktop/MainWindow.xaml.cs b/XIVChat Desktop/MainWindow.xaml.cs new file mode 100644 index 0000000..4333cfc --- /dev/null +++ b/XIVChat Desktop/MainWindow.xaml.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for MainWindow.xaml + /// + public partial class MainWindow : Window { + public MainWindow() { + InitializeComponent(); + } + } +} diff --git a/XIVChat Desktop/XIVChat Desktop.csproj b/XIVChat Desktop/XIVChat Desktop.csproj new file mode 100644 index 0000000..2c2d56c --- /dev/null +++ b/XIVChat Desktop/XIVChat Desktop.csproj @@ -0,0 +1,10 @@ + + + + WinExe + netcoreapp3.1 + XIVChat_Desktop + true + + + \ No newline at end of file diff --git a/XIVChat.sln b/XIVChat.sln new file mode 100644 index 0000000..50a3c2b --- /dev/null +++ b/XIVChat.sln @@ -0,0 +1,42 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30503.244 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChatPlugin", "XIVChatPlugin\XIVChatPlugin.csproj", "{9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9F0334E6-42D0-4CCE-888B-64DBE39D8407}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChatCommon", "XIVChatCommon\XIVChatCommon.csproj", "{6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChat Desktop", "XIVChat Desktop\XIVChat Desktop.csproj", "{D2773EAE-6B9E-4017-9681-585AAB5A99F4}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB}.Release|Any CPU.Build.0 = Release|Any CPU + {6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}.Release|Any CPU.Build.0 = Release|Any CPU + {D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2773EAE-6B9E-4017-9681-585AAB5A99F4}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5833B759-E940-40DC-8768-B20D1257FF86} + EndGlobalSection +EndGlobal diff --git a/XIVChatCommon/KeyExchange.cs b/XIVChatCommon/KeyExchange.cs new file mode 100644 index 0000000..2a71c93 --- /dev/null +++ b/XIVChatCommon/KeyExchange.cs @@ -0,0 +1,101 @@ +using Sodium; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace XIVChatCommon { + public static class KeyExchange { + public static SessionKeys ClientSessionKeys(KeyPair client, byte[] serverPublic) { + var secret = ScalarMult.Mult(client.PrivateKey, serverPublic); + + var combined = secret + .Concat(client.PublicKey) + .Concat(serverPublic) + .ToArray(); + + var hash = GenericHash.Hash(combined, null, 64); + + byte[] rx = new byte[32]; + byte[] tx = new byte[32]; + + for (int i = 0; i < 32; i++) { + rx[i] = hash[i]; + tx[i] = hash[i + 32]; + } + + return new SessionKeys(rx, tx); + } + + public static SessionKeys ServerSessionKeys(KeyPair server, byte[] clientPublic) { + var secret = ScalarMult.Mult(server.PrivateKey, clientPublic); + + var combined = secret + .Concat(clientPublic) + .Concat(server.PublicKey) + .ToArray(); + + var hash = GenericHash.Hash(combined, null, 64); + + byte[] rx = new byte[32]; + byte[] tx = new byte[32]; + + for (int i = 0; i < 32; i++) { + tx[i] = hash[i]; + rx[i] = hash[i + 32]; + } + + return new SessionKeys(rx, tx); + } + + public async static Task ServerHandshake(KeyPair server, Stream stream) { + // get client public key + byte[] clientPublic = new byte[32]; + await stream.ReadAsync(clientPublic, 0, clientPublic.Length); + + // send our public key + await stream.WriteAsync(server.PublicKey, 0, server.PublicKey.Length); + + // get shared secret and derive keys + var keys = ServerSessionKeys(server, clientPublic); + + return new HandshakeInfo(clientPublic, keys); + } + + public async static Task ClientHandshake(KeyPair client, Stream stream) { + // send our public key + await stream.WriteAsync(client.PublicKey, 0, client.PublicKey.Length); + + // get server public key + byte[] serverPublic = new byte[32]; + await stream.ReadAsync(serverPublic, 0, serverPublic.Length); + + // get shared secret and derive keys + var keys = ClientSessionKeys(client, serverPublic); + + return new HandshakeInfo(serverPublic, keys); + } + } + + public class SessionKeys { + public readonly byte[] rx; + public readonly byte[] tx; + + internal SessionKeys(byte[] rx, byte[] tx) { + this.rx = rx; + this.tx = tx; + } + } + + public class HandshakeInfo { + public byte[] RemotePublicKey { get; private set; } + public SessionKeys Keys { get; private set; } + + internal HandshakeInfo(byte[] remote, SessionKeys keys) { + this.RemotePublicKey = remote; + this.Keys = keys; + } + } +} diff --git a/XIVChatCommon/Message.cs b/XIVChatCommon/Message.cs new file mode 100644 index 0000000..4822559 --- /dev/null +++ b/XIVChatCommon/Message.cs @@ -0,0 +1,805 @@ +using MessagePack; +using MessagePack.Formatters; +using System; +using System.Collections.Generic; + +namespace XIVChatCommon { + [MessagePackObject] + public class ServerMessage : IEncodable { + [MessagePackFormatter(typeof(MillisecondsDateTimeFormatter))] + [Key(0)] + public DateTime Timestamp { get; set; } + [Key(1)] + public ChatType Channel { get; set; } + [Key(2)] + public byte[] Sender { get; set; } + [Key(3)] + public byte[] Content { get; set; } + [Key(4)] + public List Chunks { get; set; } + + [IgnoreMember] + public string ContentText => XivString.GetText(this.Content); + [IgnoreMember] + public string SenderText => XivString.GetText(this.Sender); + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.Message; + + public static ServerMessage Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [Union(1, typeof(TextChunk))] + [Union(2, typeof(IconChunk))] + [MessagePackObject] + public abstract class Chunk { + } + + [MessagePackObject] + public class TextChunk : Chunk { + [Key(0)] + public uint? FallbackColour { get; set; } + [Key(1)] + public uint? Foreground { get; set; } + [Key(2)] + public uint? Glow { get; set; } + [Key(3)] + public bool Italic { get; set; } + [Key(4)] + public string Content { get; set; } + } + + [MessagePackObject] + public class IconChunk : Chunk { + [Key(0)] + public byte Index; + } + + public class NameFormatting { + public string Before { get; private set; } = string.Empty; + public string After { get; private set; } = string.Empty; + public bool IsPresent { get; private set; } = true; + + public static NameFormatting Empty() { + return new NameFormatting { + IsPresent = false, + }; + } + + public static NameFormatting Of(string before, string after) { + return new NameFormatting { + Before = before, + After = after, + }; + } + + public static NameFormatting Basic() { + return new NameFormatting { + Before = "", + After = ": ", + }; + } + } + + public class ChatCode { + private const ushort CLEAR_7 = ~(~0 << 7); + + private readonly ushort code; + + public ChatType Type => (ChatType)(this.code & CLEAR_7); + public ChatSource Source => this.SourceFrom(11); + public ChatSource Target => this.SourceFrom(7); + private ChatSource SourceFrom(ushort shift) => (ChatSource)(1 << ((this.code >> shift) & 0xF)); + + public ChatCode(ushort code) { + this.code = code; + } + + public NameFormatting NameFormat() { + switch (this.Type) { + case ChatType.Say: + case ChatType.Shout: + case ChatType.Yell: + case ChatType.NpcAnnouncement: + case ChatType.NpcDialogue: + return NameFormatting.Of("", ": "); + case ChatType.TellOutgoing: + return NameFormatting.Of(">> ", ": "); + case ChatType.TellIncoming: + return NameFormatting.Of("", " >> "); + case ChatType.GmTell: + return NameFormatting.Of("[GM]", " >> "); + case ChatType.GmSay: + case ChatType.GmShout: + case ChatType.GmYell: + return NameFormatting.Of("[GM]", ": "); + case ChatType.GmParty: + return NameFormatting.Of("([GM]", ") "); + case ChatType.GmFreeCompany: + return NameFormatting.Of("[FC]<[GM]", "> "); + case ChatType.GmLinkshell1: + return NameFormatting.Of("[1]<[GM]", "> "); + case ChatType.GmLinkshell2: + return NameFormatting.Of("[2]<[GM]", "> "); + case ChatType.GmLinkshell3: + return NameFormatting.Of("[3]<[GM]", "> "); + case ChatType.GmLinkshell4: + return NameFormatting.Of("[4]<[GM]", "> "); + case ChatType.GmLinkshell5: + return NameFormatting.Of("[5]<[GM]", "> "); + case ChatType.GmLinkshell6: + return NameFormatting.Of("[6]<[GM]", "> "); + case ChatType.GmLinkshell7: + return NameFormatting.Of("[7]<[GM]", "> "); + case ChatType.GmLinkshell8: + return NameFormatting.Of("[8]<[GM]", "> "); + case ChatType.GmNoviceNetwork: + return NameFormatting.Of("[NOVICE][GM]", ": "); + case ChatType.Party: + case ChatType.CrossParty: + return NameFormatting.Of("(", ") "); + case ChatType.Alliance: + return NameFormatting.Of("((", ")) "); + case ChatType.PvpTeam: + return NameFormatting.Of("[PVP]<", "> "); + case ChatType.FreeCompany: + return NameFormatting.Of("[FC]<", "> "); + case ChatType.Linkshell1: + return NameFormatting.Of("[1]<", "> "); + case ChatType.Linkshell2: + return NameFormatting.Of("[2]<", "> "); + case ChatType.Linkshell3: + return NameFormatting.Of("[3]<", "> "); + case ChatType.Linkshell4: + return NameFormatting.Of("[4]<", "> "); + case ChatType.Linkshell5: + return NameFormatting.Of("[5]<", "> "); + case ChatType.Linkshell6: + return NameFormatting.Of("[6]<", "> "); + case ChatType.Linkshell7: + return NameFormatting.Of("[7]<", "> "); + case ChatType.Linkshell8: + return NameFormatting.Of("[8]<", "> "); + case ChatType.StandardEmote: + return NameFormatting.Empty(); + case ChatType.CustomEmote: + return NameFormatting.Of("", ""); + case ChatType.CrossLinkshell1: + return NameFormatting.Of("[CWLS1]<", "> "); + case ChatType.CrossLinkshell2: + return NameFormatting.Of("[CWLS2]<", "> "); + case ChatType.CrossLinkshell3: + return NameFormatting.Of("[CWLS3]<", "> "); + case ChatType.CrossLinkshell4: + return NameFormatting.Of("[CWLS4]<", "> "); + case ChatType.CrossLinkshell5: + return NameFormatting.Of("[CWLS5]<", "> "); + case ChatType.CrossLinkshell6: + return NameFormatting.Of("[CWLS6]<", "> "); + case ChatType.CrossLinkshell7: + return NameFormatting.Of("[CWLS7]<", "> "); + case ChatType.CrossLinkshell8: + return NameFormatting.Of("[CWLS8]<", "> "); + case ChatType.NoviceNetwork: + return NameFormatting.Of("[NOVICE]", ": "); + default: + return null; + } + } + + public bool IsBattle() { + switch (this.Type) { + case ChatType.Damage: + case ChatType.Miss: + case ChatType.Action: + case ChatType.Item: + case ChatType.Healing: + case ChatType.GainBuff: + case ChatType.LoseBuff: + case ChatType.GainDebuff: + case ChatType.LoseDebuff: + case ChatType.BattleSystem: + return true; + default: + return false; + } + } + + public uint? DefaultColour() { + switch (this.Type) { + case ChatType.Debug: + return Rgba(204, 204, 204); + case ChatType.Urgent: + return Rgba(255, 127, 127); + case ChatType.Notice: + return Rgba(179, 140, 255); + + case ChatType.Say: + return Rgba(247, 247, 247); + case ChatType.Shout: + return Rgba(255, 166, 102); + case ChatType.TellIncoming: + case ChatType.TellOutgoing: + case ChatType.GmTell: + return Rgba(255, 184, 222); + case ChatType.Party: + case ChatType.CrossParty: + return Rgba(102, 229, 255); + case ChatType.Alliance: + return Rgba(255, 127, 0); + case ChatType.NoviceNetwork: + case ChatType.NoviceNetworkSystem: + return Rgba(212, 255, 125); + case ChatType.Linkshell1: + case ChatType.Linkshell2: + case ChatType.Linkshell3: + case ChatType.Linkshell4: + case ChatType.Linkshell5: + case ChatType.Linkshell6: + case ChatType.Linkshell7: + case ChatType.Linkshell8: + case ChatType.CrossLinkshell1: + case ChatType.CrossLinkshell2: + case ChatType.CrossLinkshell3: + case ChatType.CrossLinkshell4: + case ChatType.CrossLinkshell5: + case ChatType.CrossLinkshell6: + case ChatType.CrossLinkshell7: + case ChatType.CrossLinkshell8: + return Rgba(212, 255, 125); + case ChatType.StandardEmote: + return Rgba(186, 255, 240); + case ChatType.CustomEmote: + return Rgba(186, 255, 240); + case ChatType.Yell: + return Rgba(255, 255, 0); + case ChatType.Echo: + return Rgba(204, 204, 204); + case ChatType.System: + case ChatType.GatheringSystem: + case ChatType.PeriodicRecruitmentNotification: + case ChatType.Orchestrion: + case ChatType.Alarm: + case ChatType.RetainerSale: + case ChatType.Sign: + case ChatType.MessageBook: + return Rgba(204, 204, 204); + case ChatType.NpcAnnouncement: + case ChatType.NpcDialogue: + return Rgba(171, 214, 71); + case ChatType.Error: + return Rgba(255, 74, 74); + case ChatType.FreeCompany: + case ChatType.FreeCompanyAnnouncement: + case ChatType.FreeCompanyLoginLogout: + return Rgba(171, 219, 229); + case ChatType.PvpTeam: + return Rgba(171, 219, 229); + case ChatType.PvpTeamAnnouncement: + case ChatType.PvpTeamLoginLogout: + return Rgba(171, 219, 229); + case ChatType.Action: + case ChatType.Item: + case ChatType.LootNotice: + return Rgba(255, 255, 176); + case ChatType.Progress: + return Rgba(255, 222, 115); + case ChatType.LootRoll: + case ChatType.RandomNumber: + return Rgba(199, 191, 158); + case ChatType.Crafting: + case ChatType.Gathering: + return Rgba(222, 191, 247); + case ChatType.Damage: + return Rgba(255, 125, 125); + case ChatType.Miss: + return Rgba(204, 204, 204); + case ChatType.Healing: + return Rgba(212, 255, 125); + case ChatType.GainBuff: + case ChatType.LoseBuff: + return Rgba(148, 191, 255); + case ChatType.GainDebuff: + case ChatType.LoseDebuff: + return Rgba(255, 138, 196); + case ChatType.BattleSystem: + return Rgba(204, 204, 204); + default: + return null; + } + } + + private static uint Rgba(byte red, byte green, byte blue, byte alpha = 0xFF) => alpha + | (uint)(red << 24) + | (uint)(green << 16) + | (uint)(blue << 8); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1028:Enum Storage should be Int32")] + public enum ChatType : ushort { + Debug = 1, + Urgent = 2, + Notice = 3, + Say = 10, + Shout = 11, + TellOutgoing = 12, + TellIncoming = 13, + Party = 14, + Alliance = 15, + Linkshell1 = 16, + Linkshell2 = 17, + Linkshell3 = 18, + Linkshell4 = 19, + Linkshell5 = 20, + Linkshell6 = 21, + Linkshell7 = 22, + Linkshell8 = 23, + FreeCompany = 24, + NoviceNetwork = 27, + CustomEmote = 28, + StandardEmote = 29, + Yell = 30, + // 31 - also party? + CrossParty = 32, + PvpTeam = 36, + CrossLinkshell1 = 37, + Damage = 41, + Miss = 42, + Action = 43, + Item = 44, + Healing = 45, + GainBuff = 46, + GainDebuff = 47, + LoseBuff = 48, + LoseDebuff = 49, + Alarm = 55, + Echo = 56, + System = 57, + BattleSystem = 58, + GatheringSystem = 59, + Error = 60, + NpcDialogue = 61, + LootNotice = 62, + Progress = 64, + LootRoll = 65, + Crafting = 66, + Gathering = 67, + NpcAnnouncement = 68, + FreeCompanyAnnouncement = 69, + FreeCompanyLoginLogout = 70, + RetainerSale = 71, + PeriodicRecruitmentNotification = 72, + Sign = 73, + RandomNumber = 74, + NoviceNetworkSystem = 75, + Orchestrion = 76, + PvpTeamAnnouncement = 77, + PvpTeamLoginLogout = 78, + MessageBook = 79, + GmTell = 80, + GmSay = 81, + GmShout = 82, + GmYell = 83, + GmParty = 84, + GmFreeCompany = 85, + GmLinkshell1 = 86, + GmLinkshell2 = 87, + GmLinkshell3 = 88, + GmLinkshell4 = 89, + GmLinkshell5 = 90, + GmLinkshell6 = 91, + GmLinkshell7 = 92, + GmLinkshell8 = 93, + GmNoviceNetwork = 94, + CrossLinkshell2 = 101, + CrossLinkshell3 = 102, + CrossLinkshell4 = 103, + CrossLinkshell5 = 104, + CrossLinkshell6 = 105, + CrossLinkshell7 = 106, + CrossLinkshell8 = 107, + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1028:Enum Storage should be Int32")] + public enum ChatSource : ushort { + Self = 2, + PartyMember = 4, + AllianceMember = 8, + Other = 16, + EngagedEnemy = 32, + UnengagedEnemy = 64, + FriendlyNpc = 128, + SelfPet = 256, + PartyPet = 512, + AlliancePet = 1024, + OtherPet = 2048, + } + + [MessagePackObject] + public class ClientMessage : IEncodable { + [Key(0)] + public string Content { get; set; } + + [IgnoreMember] + protected override byte Code => (byte)ClientOperation.Message; + + public static ClientMessage Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + public enum ServerOperation : byte { + /// + /// Sent in response to a client ping. Has no payload. + /// + Pong = 1, + /// + /// A message was sent in game and is being relayed to the client. + /// + Message = 2, + /// + /// The server is shutting down. Clients should send no response and close their sockets. Has no payload. + /// + Shutdown = 3, + PlayerData = 4, + Availability = 5, + Channel = 6, + Backlog = 7, + PlayerList = 8, + LinkshellList = 9, + } + + [MessagePackObject] + public class PlayerData : IEncodable { + [Key(0)] + public readonly string homeWorld; + [Key(1)] + public readonly string currentWorld; + [Key(2)] + public readonly string location; + [Key(3)] + public readonly string name; + + public PlayerData(string homeWorld, string currentWorld, string location, string name) { + this.homeWorld = homeWorld; + this.currentWorld = currentWorld; + this.location = location; + this.name = name; + } + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.PlayerData; + + public static PlayerData Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + public class EmptyPlayerData : IEncodable { + public static EmptyPlayerData Instance { get; } = new EmptyPlayerData(); + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.PlayerData; + + protected override byte[] PayloadEncode() { + return new byte[0]; + } + } + + [MessagePackObject] + public class Availability : IEncodable { + [Key(0)] + public readonly bool available; + + public Availability(bool available) { + this.available = available; + } + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.Availability; + + public static Availability Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [MessagePackObject] + public class ServerChannel : IEncodable { + [Key(0)] + public readonly byte channel; + [Key(1)] + public readonly string name; + + [IgnoreMember] + public InputChannel InputChannel => (InputChannel)this.channel; + + protected override byte Code => (byte)ServerOperation.Channel; + + public ServerChannel(InputChannel channel, string name) : this((byte)channel, name) { } + + public ServerChannel(byte channel, string name) { + this.channel = channel; + this.name = name; + } + + public static ServerChannel Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + public enum InputChannel : byte { + Tell = 0, + Say = 1, + Party = 2, + Alliance = 3, + Yell = 4, + Shout = 5, + FreeCompany = 6, + PvpTeam = 7, + NoviceNetwork = 8, + CrossLinkshell1 = 9, + CrossLinkshell2 = 10, + CrossLinkshell3 = 11, + CrossLinkshell4 = 12, + CrossLinkshell5 = 13, + CrossLinkshell6 = 14, + CrossLinkshell7 = 15, + CrossLinkshell8 = 16, + // 17 - unused? + // 18 - unused? + Linkshell1 = 19, + Linkshell2 = 20, + Linkshell3 = 21, + Linkshell4 = 22, + Linkshell5 = 23, + Linkshell6 = 24, + Linkshell7 = 25, + Linkshell8 = 26, + } + + public class Pong : IEncodable { + public static Pong Instance { get; } = new Pong(); + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.Pong; + + protected override byte[] PayloadEncode() { + return new byte[0]; + } + } + + public class Ping : IEncodable { + public static Ping Instance { get; } = new Ping(); + + [IgnoreMember] + protected override byte Code => (byte)ClientOperation.Ping; + + protected override byte[] PayloadEncode() { + return new byte[0]; + } + } + + public class ServerShutdown : IEncodable { + public static ServerShutdown Instance { get; } = new ServerShutdown(); + + [IgnoreMember] + protected override byte Code => (byte)ServerOperation.Shutdown; + + protected override byte[] PayloadEncode() { + return new byte[0]; + } + } + + public class ClientShutdown : IEncodable { + public static ClientShutdown Instance { get; } = new ClientShutdown(); + + [IgnoreMember] + protected override byte Code => (byte)ClientOperation.Shutdown; + + protected override byte[] PayloadEncode() { + return new byte[0]; + } + } + + [MessagePackObject] + public class ServerBacklog : IEncodable { + [Key(0)] + public readonly ServerMessage[] messages; + + protected override byte Code => (byte)ServerOperation.Backlog; + + public ServerBacklog(ServerMessage[] messages) { + this.messages = messages; + } + + public static ServerBacklog Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [MessagePackObject] + public class ClientBacklog : IEncodable { + [Key(0)] + public ushort Amount { get; set; } + + protected override byte Code => (byte)ClientOperation.Backlog; + + public static ClientBacklog Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [MessagePackObject] + public class ClientCatchUp : IEncodable { + [MessagePackFormatter(typeof(MillisecondsDateTimeFormatter))] + [Key(0)] + public DateTime After { get; set; } + + protected override byte Code => (byte)ClientOperation.CatchUp; + + public static ClientCatchUp Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [MessagePackObject] + public class ServerPlayerList : IEncodable { + [Key(0)] + public PlayerListType Type { get; set; } + + [Key(1)] + public Player[] Players { get; set; } + + protected override byte Code => (byte)ServerOperation.PlayerList; + + public static ServerPlayerList Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + [MessagePackObject] + public class ClientPlayerList : IEncodable { + [Key(0)] + public PlayerListType Type { get; set; } + + protected override byte Code => (byte)ClientOperation.PlayerList; + + public static ClientPlayerList Decode(byte[] bytes) { + return MessagePackSerializer.Deserialize(bytes); + } + + protected override byte[] PayloadEncode() { + return MessagePackSerializer.Serialize(this); + } + } + + public enum PlayerListType : byte { + Party = 1, + Friend = 2, + Linkshell = 3, + CrossLinkshell = 4, + } + + [MessagePackObject] + public class Player { + [Key(0)] + public string Name { get; set; } + [Key(1)] + public string FreeCompany { get; set; } + [Key(2)] + public ulong Status { get; set; } + [Key(3)] + public ushort CurrentWorld { get; set; } + [Key(4)] + public string CurrentWorldName { get; set; } + [Key(5)] + public ushort HomeWorld { get; set; } + [Key(6)] + public string HomeWorldName { get; set; } + [Key(7)] + public ushort Territory { get; set; } + [Key(8)] + public string TerritoryName { get; set; } + [Key(9)] + public byte Job { get; set; } + [Key(10)] + public string JobName { get; set; } + [Key(11)] + public byte GrandCompany { get; set; } + [Key(12)] + public string GrandCompanyName { get; set; } + [Key(13)] + public byte Languages { get; set; } + [Key(14)] + public byte MainLanguage { get; set; } + } + + public abstract class IEncodable { + protected abstract byte Code { get; } + protected abstract byte[] PayloadEncode(); + + public byte[] Encode() { + byte[] payload = this.PayloadEncode(); + + if (payload.Length == 0) { + return new byte[] { this.Code }; + } + + byte[] bytes = new byte[1 + payload.Length]; + bytes[0] = this.Code; + Array.Copy(payload, 0, bytes, 1, payload.Length); + return bytes; + } + } + + public enum ClientOperation : byte { + /// + /// The client is sending data to the server to keep the socket alive. Has no payload. + /// + Ping = 1, + /// + /// The client has a message to be sent in the game and is relaying it to the server. + /// + Message = 2, + /// + /// The client is shutting down. Clients should send this and close their socket for a clean shutdown. + /// + Shutdown = 3, + Backlog = 4, + CatchUp = 5, + PlayerList = 6, + LinkshellList = 7, + } + + public class MillisecondsDateTimeFormatter : IMessagePackFormatter { + private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + + public DateTime Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { + var millis = reader.ReadInt64(); + return Epoch.AddMilliseconds(millis); + } + + public void Serialize(ref MessagePackWriter writer, DateTime value, MessagePackSerializerOptions options) { + var millis = (long)(value.ToUniversalTime() - Epoch).TotalMilliseconds; + writer.WriteInt64(millis); + } + } +} diff --git a/XIVChatCommon/Properties/AssemblyInfo.cs b/XIVChatCommon/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..8cb897c --- /dev/null +++ b/XIVChatCommon/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 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("XIVChatCommon")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("XIVChatCommon")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("6f426eab-15d4-48ac-80d2-2d2df2ce5ee0")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/XIVChatCommon/SecretMessage.cs b/XIVChatCommon/SecretMessage.cs new file mode 100644 index 0000000..2150633 --- /dev/null +++ b/XIVChatCommon/SecretMessage.cs @@ -0,0 +1,56 @@ +using Sodium; +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace XIVChatCommon { + public static class SecretMessage { + private const uint MAX_MESSAGE_LEN = 128_000; + + public async static Task ReadSecretMessage(Stream s, byte[] key, CancellationToken token = default) { + int read = 0; + + byte[] header = new byte[4 + 24]; + while (read < header.Length) { + read += await s.ReadAsync(header, read, header.Length - read, token); + } + + uint length = BitConverter.ToUInt32(header, 0); + byte[] nonce = header.Skip(4).ToArray(); + + if (length > MAX_MESSAGE_LEN) { + throw new ArgumentOutOfRangeException($"Encrypted message specified a size of {length}, which is greater than the limit of {MAX_MESSAGE_LEN}"); + } + + byte[] ciphertext = new byte[length]; + read = 0; + while (read < ciphertext.Length) { + read += await s.ReadAsync(ciphertext, read, ciphertext.Length - read, token); + } + + return SecretBox.Open(ciphertext, nonce, key); + } + + public async static Task SendSecretMessage(Stream s, byte[] key, byte[] message, CancellationToken token = default) { + byte[] nonce = SecretBox.GenerateNonce(); + byte[] ciphertext = SecretBox.Create(message, nonce, key); + byte[] len = BitConverter.GetBytes((uint)ciphertext.Length); + + if (ciphertext.Length > MAX_MESSAGE_LEN) { + throw new ArgumentOutOfRangeException($"Encrypted message would be {len} bytes long, which is larger than the limit of {MAX_MESSAGE_LEN}"); + } + + await s.WriteAsync(len, 0, len.Length, token); + await s.WriteAsync(nonce, 0, nonce.Length, token); + await s.WriteAsync(ciphertext, 0, ciphertext.Length, token); + } + + public async static Task SendSecretMessage(Stream s, byte[] key, IEncodable message, CancellationToken token = default) { + await SendSecretMessage(s, key, message.Encode(), token); + } + + public static int MacSize() => 16; + } +} diff --git a/XIVChatCommon/XIVChatCommon.csproj b/XIVChatCommon/XIVChatCommon.csproj new file mode 100644 index 0000000..7cb7888 --- /dev/null +++ b/XIVChatCommon/XIVChatCommon.csproj @@ -0,0 +1,88 @@ + + + + + Debug + AnyCPU + {6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0} + Library + Properties + XIVChatCommon + XIVChatCommon + v4.8 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + 8.0 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + 8.0 + + + + ..\packages\MessagePack.2.1.194\lib\netstandard2.0\MessagePack.dll + + + ..\packages\MessagePack.Annotations.2.1.194\lib\netstandard2.0\MessagePack.Annotations.dll + + + ..\packages\Microsoft.Bcl.AsyncInterfaces.1.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll + + + ..\packages\Sodium.Core.1.2.3\lib\netstandard2.0\Sodium.Core.dll + + + + ..\packages\System.Buffers.4.4.0\lib\netstandard2.0\System.Buffers.dll + + + + ..\packages\System.Memory.4.5.3\lib\netstandard2.0\System.Memory.dll + + + + ..\packages\System.Numerics.Vectors.4.4.0\lib\net46\System.Numerics.Vectors.dll + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.5.2\lib\netstandard2.0\System.Runtime.CompilerServices.Unsafe.dll + + + ..\packages\System.Threading.Tasks.Extensions.4.5.3\lib\netstandard2.0\System.Threading.Tasks.Extensions.dll + + + ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChatCommon/XivString.cs b/XIVChatCommon/XivString.cs new file mode 100644 index 0000000..c01e672 --- /dev/null +++ b/XIVChatCommon/XivString.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace XIVChatCommon { + public static class XivString { + private const byte START = 2; + private const byte END = 3; + + public static List ToChunks(byte[] bytes) { + var chunks = new List(); + var stringBytes = new List(); + + var italic = false; + uint? foreground = null; + uint? glow = null; + + Action appendCurrent = (bool clear) => { + var text = Encoding.UTF8.GetString(stringBytes.ToArray()); + chunks.Add(new TextChunk { + Foreground = foreground, + Glow = glow, + Italic = italic, + Content = text, + }); + if (clear) { + stringBytes.Clear(); + } + }; + + var reader = new BinaryReader(new MemoryStream(bytes)); + while (reader.BaseStream.Position < reader.BaseStream.Length) { + var b = reader.ReadByte(); + if (b == START) { + var kind = reader.ReadByte(); // kind + var len = GetInteger(reader); // data length + var data = new BinaryReader(new MemoryStream(reader.ReadBytes((int)len))); // data + var end = reader.ReadByte(); // end + if (end != END) { + throw new ArgumentException("Input was not a valid XivString"); + } + + switch (kind) { + // icon processing + case 0x12: + var spriteIndex = GetInteger(data); + chunks.Add(new IconChunk { + Index = (byte)spriteIndex, + }); + break; + // italics processing + case 0x1a: + var newStatus = GetInteger(data) == 1; + + var appendNow = (italic && !newStatus) || (!italic && newStatus); + if (!appendNow) { + break; + } + + appendCurrent(true); + + italic = newStatus; + break; + // foreground + case 0x48: + break; + // glow + case 0x49: + break; + } + continue; + } + stringBytes.Add(b); + } + + return chunks; + } + + public static string GetText(byte[] bytes) { + var stringBytes = new List(); + + var reader = new BinaryReader(new MemoryStream(bytes)); + while (reader.BaseStream.Position < reader.BaseStream.Length) { + var b = reader.ReadByte(); + if (b == START) { + reader.ReadByte(); // kind + var len = GetInteger(reader); // data length + reader.ReadBytes((int)len); // data + var end = reader.ReadByte(); // end + if (end != END) { + throw new ArgumentException("Input was not a valid XivString"); + } + continue; + } + stringBytes.Add(b); + } + + return Encoding.UTF8.GetString(stringBytes.ToArray()); + } + + // Thanks, Dalamud + + protected enum IntegerType { + // used as an internal marker; sometimes single bytes are bare with no marker at all + None = 0, + + Byte = 0xF0, + ByteTimes256 = 0xF1, + Int16 = 0xF2, + ByteSHL16 = 0xF3, + Int16Packed = 0xF4, // seen in map links, seemingly 2 8-bit values packed into 2 bytes with only one marker + Int16SHL8 = 0xF5, + Int24Special = 0xF6, // unsure how different form Int24 - used for hq items that add 1 million, also used for normal 24-bit values in map links + Int8SHL24 = 0xF7, + Int8SHL8Int8 = 0xF8, + Int8SHL8Int8SHL8 = 0xF9, + Int24 = 0xFA, + Int16SHL16 = 0xFB, + Int24Packed = 0xFC, // used in map links- sometimes short+byte, sometimes... not?? + Int16Int8SHL8 = 0xFD, + Int32 = 0xFE + } + + private static uint GetInteger(BinaryReader input) { + var t = input.ReadByte(); + var type = (IntegerType)t; + return GetInteger(input, type); + } + + private static uint GetInteger(BinaryReader input, IntegerType type) { + const byte ByteLengthCutoff = 0xF0; + + var t = (byte)type; + if (t < ByteLengthCutoff) { + return (uint)(t - 1); + } + + switch (type) { + case IntegerType.Byte: + return input.ReadByte(); + + case IntegerType.ByteTimes256: + return input.ReadByte() * (uint)256; + case IntegerType.ByteSHL16: + return (uint)(input.ReadByte() << 16); + case IntegerType.Int8SHL24: + return (uint)(input.ReadByte() << 24); + case IntegerType.Int8SHL8Int8: { + var v = 0; + v |= input.ReadByte() << 24; + v |= input.ReadByte(); + return (uint)v; + } + case IntegerType.Int8SHL8Int8SHL8: { + var v = 0; + v |= input.ReadByte() << 24; + v |= input.ReadByte() << 8; + return (uint)v; + } + + + case IntegerType.Int16: + // fallthrough - same logic + case IntegerType.Int16Packed: { + var v = 0; + v |= input.ReadByte() << 8; + v |= input.ReadByte(); + return (uint)v; + } + case IntegerType.Int16SHL8: { + var v = 0; + v |= input.ReadByte() << 16; + v |= input.ReadByte() << 8; + return (uint)v; + } + case IntegerType.Int16SHL16: { + var v = 0; + v |= input.ReadByte() << 24; + v |= input.ReadByte() << 16; + return (uint)v; + } + + case IntegerType.Int24Special: + // Fallthrough - same logic + case IntegerType.Int24Packed: + // fallthrough again + case IntegerType.Int24: { + var v = 0; + v |= input.ReadByte() << 16; + v |= input.ReadByte() << 8; + v |= input.ReadByte(); + return (uint)v; + } + case IntegerType.Int16Int8SHL8: { + var v = 0; + v |= input.ReadByte() << 24; + v |= input.ReadByte() << 16; + v |= input.ReadByte() << 8; + return (uint)v; + } + case IntegerType.Int32: { + var v = 0; + v |= input.ReadByte() << 24; + v |= input.ReadByte() << 16; + v |= input.ReadByte() << 8; + v |= input.ReadByte(); + return (uint)v; + } + + default: + throw new NotSupportedException(); + } + } + } +} diff --git a/XIVChatCommon/app.config b/XIVChatCommon/app.config new file mode 100644 index 0000000..9274e6c --- /dev/null +++ b/XIVChatCommon/app.config @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChatCommon/packages.config b/XIVChatCommon/packages.config new file mode 100644 index 0000000..a97cf26 --- /dev/null +++ b/XIVChatCommon/packages.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChatPlugin/Configuration.cs b/XIVChatPlugin/Configuration.cs new file mode 100644 index 0000000..04e50f3 --- /dev/null +++ b/XIVChatPlugin/Configuration.cs @@ -0,0 +1,29 @@ +using Dalamud.Configuration; +using Sodium; +using System; +using System.Collections.Generic; + +namespace XIVChatPlugin { + public class Configuration : IPluginConfiguration { + private Plugin plugin; + + public int Version { get; set; } = 1; + public ushort Port { get; set; } = 14777; + + public bool BacklogEnabled { get; set; } = true; + public ushort BacklogCount { get; set; } = 100; + + public bool SendBattle { get; set; } = true; + + public Dictionary> TrustedKeys { get; set; } = new Dictionary>(); + public KeyPair KeyPair { get; set; } = null; + + public void Initialise(Plugin plugin) { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null"); + } + + public void Save() { + this.plugin.Interface.SavePluginConfig(this); + } + } +} diff --git a/XIVChatPlugin/Extensions.cs b/XIVChatPlugin/Extensions.cs new file mode 100644 index 0000000..ce81d88 --- /dev/null +++ b/XIVChatPlugin/Extensions.cs @@ -0,0 +1,65 @@ +using Dalamud.Plugin; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +namespace XIVChatPlugin { + public static class Extensions { + public static string ToHexString(this byte[] bytes, bool upper = false, string separator = "") { + return string.Join(separator, bytes.Select(b => b.ToString(upper ? "X2" : "x2"))); + } + + //public static List ToColours(this byte[] bytes) { + // var colours = new List(); + + // uint colour = 0xFF; + // for (int i = 0; i < bytes.Length; i++) { + // var idx = i % 3; + + // if (i != 0 && idx == 0) { + // colours.Add(colour); + // colour = 0xFF; + // } + + // colour |= (uint)bytes[i] << ((4 - idx - 1) * 8); + // } + + // colours.Add(colour); + + // return colours; + //} + + public static List ToColours(this byte[] bytes) { + var colours = new List(); + + var colour = new Vector4(0f, 0f, 0f, 1f); + for (int i = 0; i < bytes.Length; i++) { + var idx = i % 3; + + if (i != 0 && idx == 0) { + colours.Add(colour); + colour = new Vector4(0f, 0f, 0f, 1f); + } + + switch (idx) { + case 0: + colour.X = bytes[i] / 255f; + break; + case 1: + colour.Y = bytes[i] / 255f; + break; + case 2: + colour.Z = bytes[i] / 255f; + break; + default: + throw new ApplicationException("unreachable code reached"); + } + } + + colours.Add(colour); + + return colours; + } + } +} diff --git a/XIVChatPlugin/GameFunctions.cs b/XIVChatPlugin/GameFunctions.cs new file mode 100644 index 0000000..19a9882 --- /dev/null +++ b/XIVChatPlugin/GameFunctions.cs @@ -0,0 +1,230 @@ +using Dalamud.Hooking; +using Lumina.Excel.GeneratedSheets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using XIVChatCommon; + +namespace XIVChatPlugin { + public class GameFunctions : IDisposable { + private readonly Plugin plugin; + + private delegate IntPtr GetUIBaseDelegate(); + private delegate IntPtr GetUIModuleDelegate(IntPtr basePtr); + private delegate void EasierProcessChatBoxDelegate(IntPtr uiModule, IntPtr message, IntPtr unused, byte a4); + private delegate byte RequestFriendListDelegate(IntPtr manager); + private delegate int FormatFriendListNameDelegate(long a1, long a2, long a3, int a4, IntPtr data, long a6); + private delegate IntPtr OnReceiveFriendListChunkDelegate(IntPtr a1, IntPtr data); + + private readonly Hook friendListHook; + private readonly Hook formatHook; + private readonly Hook receiveChunkHook; + + private readonly GetUIModuleDelegate GetUIModule; + private readonly EasierProcessChatBoxDelegate _EasierProcessChatBox; + + private readonly IntPtr uiModulePtr; + private IntPtr friendListManager = IntPtr.Zero; + + private bool requestingFriendList = false; + public bool RequestingFriendList => this.requestingFriendList; + private readonly List friends = new List(); + + public delegate void ReceiveFriendListHandler(List friends); + public event ReceiveFriendListHandler ReceiveFriendList; + + public GameFunctions(Plugin plugin) { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null"); + + var getUIModulePtr = this.plugin.Interface.TargetModuleScanner.ScanText("E8 ?? ?? ?? ?? 48 83 7F ?? 00 48 8B F0"); + var easierProcessChatBoxPtr = this.plugin.Interface.TargetModuleScanner.ScanText("48 89 5C 24 ?? 57 48 83 EC 20 48 8B FA 48 8B D9 45 84 C9"); + var friendListPtr = this.plugin.Interface.TargetModuleScanner.ScanText("40 53 48 81 EC 80 0F 00 00 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 84 24 ?? ?? ?? ?? 48 8B D9 48 8B 0D ?? ?? ?? ?? E8 ?? ?? ?? ?? 48 85 C0 0F 84 ?? ?? ?? ?? 44 0F B6 43 ?? 33 C9"); + var formatPtr = this.plugin.Interface.TargetModuleScanner.ScanText("48 89 5C 24 ?? 48 89 6C 24 ?? 48 89 74 24 ?? 41 56 48 83 EC 30 48 8B 6C 24 ??"); + var recvChunkPtr = this.plugin.Interface.TargetModuleScanner.ScanText("48 89 5C 24 ?? 56 48 83 EC 20 48 8B 0D ?? ?? ?? ?? 48 8B F2"); + this.uiModulePtr = this.plugin.Interface.TargetModuleScanner.GetStaticAddressFromSig("48 8B 0D ?? ?? ?? ?? 48 8D 54 24 ?? 48 83 C1 10 E8 ?? ?? ?? ??"); + + this.GetUIModule = Marshal.GetDelegateForFunctionPointer(getUIModulePtr); + this._EasierProcessChatBox = Marshal.GetDelegateForFunctionPointer(easierProcessChatBoxPtr); + + this.friendListHook = new Hook(friendListPtr, new RequestFriendListDelegate(this.OnRequestFriendList)); + this.formatHook = new Hook(formatPtr, new FormatFriendListNameDelegate(this.OnFormatFriendList)); + this.receiveChunkHook = new Hook(recvChunkPtr, new OnReceiveFriendListChunkDelegate(this.OnReceiveFriendList)); + + this.friendListHook.Enable(); + this.formatHook.Enable(); + this.receiveChunkHook.Enable(); + } + + public void ProcessChatBox(string message) { + IntPtr uiModule = this.GetUIModule(Marshal.ReadIntPtr(this.uiModulePtr)); + + if (uiModule == IntPtr.Zero) { + throw new ApplicationException("uiModule was null"); + } + + using (var payload = new ChatPayload(message)) { + IntPtr mem1 = Marshal.AllocHGlobal(400); + Marshal.StructureToPtr(payload, mem1, false); + + this._EasierProcessChatBox(uiModule, mem1, IntPtr.Zero, 0); + + Marshal.FreeHGlobal(mem1); + } + } + + public bool RequestFriendList() { + if (this.friendListManager == IntPtr.Zero || this.friendListHook == null) { + return false; + } + + this.requestingFriendList = true; + this.friendListHook.Original(this.friendListManager); + return true; + } + + private byte OnRequestFriendList(IntPtr manager) { + this.friendListManager = manager; + return this.friendListHook.Original(manager); + } + + private int OnFormatFriendList(long a1, long a2, long a3, int a4, IntPtr data, long a6) { + // have to call this first to populate cross-world info + var ret = this.formatHook.Original(a1, a2, a3, a4, data, a6); + + if (!this.RequestingFriendList) { + return ret; + } + + var entry = Marshal.PtrToStructure(data); + + string jobName = null; + if (entry.job > 0) { + jobName = this.plugin.Interface.Data.GetExcelSheet().GetRow(entry.job)?.Name; + } + + var player = new Player { + Name = entry.Name(), + FreeCompany = entry.FreeCompany(), + Status = entry.flags, + + CurrentWorld = entry.currentWorldId, + CurrentWorldName = this.plugin.Interface.Data.GetExcelSheet().GetRow(entry.currentWorldId)?.Name, + HomeWorld = entry.homeWorldId, + HomeWorldName = this.plugin.Interface.Data.GetExcelSheet().GetRow(entry.homeWorldId)?.Name, + + Territory = entry.territoryId, + TerritoryName = this.plugin.Interface.Data.GetExcelSheet().GetRow(entry.territoryId)?.PlaceName?.Value?.Name, + + Job = entry.job, + JobName = jobName, + + GrandCompany = entry.grandCompany, + GrandCompanyName = this.plugin.Interface.Data.GetExcelSheet().GetRow(entry.grandCompany)?.Name, + + Languages = entry.langsEnabled, + MainLanguage = entry.mainLanguage, + }; + this.friends.Add(player); + + return ret; + } + + private IntPtr OnReceiveFriendList(IntPtr a1, IntPtr data) { + var ret = this.receiveChunkHook.Original(a1, data); + + // + 0xc + // 1 = party + // 2 = friends + // 3 = linkshell + // doesn't run (though same memory gets updated) for cwl or blacklist + + // + 0x8 is current number of results returned or 0 when end of list + + if (!this.RequestingFriendList) { + goto Return; + } + + if (Marshal.ReadByte(data + 0xc) != 2 || Marshal.ReadInt16(data + 0x8) != 0) { + goto Return; + } + + this.ReceiveFriendList(this.friends); + this.friends.Clear(); + this.requestingFriendList = false; + + Return: + return ret; + } + + public void Dispose() { + this.friendListHook?.Dispose(); + this.formatHook?.Dispose(); + this.receiveChunkHook?.Dispose(); + } + } + + [StructLayout(LayoutKind.Explicit)] + struct ChatPayload : IDisposable { + [FieldOffset(0)] + readonly IntPtr textPtr; + [FieldOffset(16)] + readonly ulong textLen; + + [FieldOffset(8)] + readonly ulong unk1; + [FieldOffset(24)] + readonly ulong unk2; + + internal ChatPayload(string text) { + byte[] stringBytes = Encoding.UTF8.GetBytes(text); + this.textPtr = Marshal.AllocHGlobal(stringBytes.Length + 30); + Marshal.Copy(stringBytes, 0, this.textPtr, stringBytes.Length); + Marshal.WriteByte(this.textPtr + stringBytes.Length, 0); + + this.textLen = (ulong)(stringBytes.Length + 1); + + this.unk1 = 64; + this.unk2 = 0; + } + + public void Dispose() { + Marshal.FreeHGlobal(this.textPtr); + } + } + + [StructLayout(LayoutKind.Sequential)] + struct FriendListEntryRaw { + readonly ulong unk1; + internal ulong flags; + readonly uint unk2; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] + readonly byte[] unk3; + internal readonly ushort currentWorldId; + internal readonly ushort homeWorldId; + internal readonly ushort territoryId; + internal readonly byte grandCompany; + internal readonly byte mainLanguage; + internal readonly byte langsEnabled; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] + readonly byte[] unk4; + internal readonly byte job; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)] + readonly byte[] name; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)] + readonly byte[] fc; + + private static string HandleString(byte[] bytes) { + byte[] nonNull = bytes.TakeWhile(b => b != 0).ToArray(); + if (nonNull.Length == 0) { + return null; + } + + return Encoding.UTF8.GetString(nonNull); + } + + public string Name() => HandleString(this.name); + public string FreeCompany() => HandleString(this.fc); + } +} diff --git a/XIVChatPlugin/ILMerge.props b/XIVChatPlugin/ILMerge.props new file mode 100644 index 0000000..e50312a --- /dev/null +++ b/XIVChatPlugin/ILMerge.props @@ -0,0 +1,67 @@ + + + + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XIVChatPlugin/ILMergeOrder.txt b/XIVChatPlugin/ILMergeOrder.txt new file mode 100644 index 0000000..3fda7f5 --- /dev/null +++ b/XIVChatPlugin/ILMergeOrder.txt @@ -0,0 +1,4 @@ +# this file contains the partial list of the merged assemblies in the merge order +# you can fill it from the obj\CONFIG\PROJECT.ilmerge generated on every build +# and finetune merge order to your satisfaction + diff --git a/XIVChatPlugin/Plugin.cs b/XIVChatPlugin/Plugin.cs new file mode 100644 index 0000000..30251fc --- /dev/null +++ b/XIVChatPlugin/Plugin.cs @@ -0,0 +1,106 @@ +using Dalamud.Game.Command; +using Dalamud.Hooking; +using Dalamud.Plugin; +using System; +using System.IO; +using System.Reflection; + +namespace XIVChatPlugin { + public class Plugin : IDalamudPlugin { + private bool disposedValue; + + public string Name => "XIVChat"; + + internal string Location { get; private set; } = Assembly.GetExecutingAssembly().Location; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "LivePluginLoader")] + private void SetLocation(string path) { + this.Location = path; + } + + public DalamudPluginInterface Interface { get; private set; } + public Configuration Config { get; private set; } + public PluginUI Ui { get; private set; } + public Server Server { get; private set; } + public GameFunctions Functions { get; private set; } + + private delegate byte ChatChannelChangeDelegate(IntPtr a1, uint channel); + private Hook chatChannelChangeHook; + + public void Initialize(DalamudPluginInterface pluginInterface) { + this.Interface = pluginInterface ?? throw new ArgumentNullException(nameof(pluginInterface), "DalamudPluginInterface cannot be null"); + + // load libsodium.so from debug location if in debug mode +#if DEBUG + string path = Environment.GetEnvironmentVariable("PATH"); + string newPath = Path.GetDirectoryName(this.Location); + Environment.SetEnvironmentVariable("PATH", $"{path};{newPath}"); +#endif + + this.Config = (Configuration)this.Interface.GetPluginConfig() ?? new Configuration(); + this.Config.Initialise(this); + + this.Functions = new GameFunctions(this); + var funcPtr = this.Interface.TargetModuleScanner.ScanText("40 55 48 8D 6C 24 ?? 48 81 EC A0 00 00 00 48 8B 05 ?? ?? ?? ?? 48 33 C4 48 89 45 ?? 48 8B 0D ?? ?? ?? ?? 33 C0 48 83 C1 10 89 45 ?? C7 45 ?? 01 00 00 00"); + if (funcPtr == IntPtr.Zero) { + PluginLog.LogError("Could not sig chat channel change function"); + } else { + this.chatChannelChangeHook = new Hook(funcPtr, new ChatChannelChangeDelegate(this.ChangeChatChannelDetour)); + this.chatChannelChangeHook.Enable(); + } + + this.Ui = new PluginUI(this); + + this.Server = new Server(this); + this.Server.Spawn(); + + this.Interface.UiBuilder.OnBuildUi += this.Ui.Draw; + this.Interface.UiBuilder.OnOpenConfigUi += this.Ui.OpenSettings; + this.Interface.Framework.OnUpdateEvent += this.Server.OnFrameworkUpdate; + this.Interface.Framework.Gui.Chat.OnChatMessage += this.Server.OnChat; + this.Interface.ClientState.OnLogin += this.Server.OnLogIn; + this.Interface.ClientState.OnLogout += this.Server.OnLogOut; + this.Interface.ClientState.TerritoryChanged += this.Server.OnTerritoryChange; + this.Interface.CommandManager.AddHandler("/xivchat", new CommandInfo(this.OnCommand) { + HelpMessage = "Opens the config for the XIVChat plugin", + }); + } + + private byte ChangeChatChannelDetour(IntPtr a1, uint channel) { + // a1 + 0xfd0 is the chat channel byte (including for when clicking on shout) + this.Server.OnChatChannelChange(channel); + return this.chatChannelChangeHook.Original(a1, channel); + } + + private void OnCommand(string command, string args) { + this.Ui.OpenSettings(null, null); + } + + protected virtual void Dispose(bool disposing) { + if (!this.disposedValue) { + if (disposing) { + this.Server.Dispose(); + + this.Interface.UiBuilder.OnBuildUi -= this.Ui.Draw; + this.Interface.UiBuilder.OnOpenConfigUi -= this.Ui.OpenSettings; + this.Interface.Framework.OnUpdateEvent -= this.Server.OnFrameworkUpdate; + this.Interface.Framework.Gui.Chat.OnChatMessage -= this.Server.OnChat; + this.Interface.ClientState.OnLogin -= this.Server.OnLogIn; + this.Interface.ClientState.OnLogout -= this.Server.OnLogOut; + this.Interface.ClientState.TerritoryChanged -= this.Server.OnTerritoryChange; + this.Interface.CommandManager.RemoveHandler("/xivchat"); + + this.chatChannelChangeHook?.Dispose(); + } + + this.disposedValue = true; + } + } + + public void Dispose() { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + this.Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/XIVChatPlugin/PluginUI.cs b/XIVChatPlugin/PluginUI.cs new file mode 100644 index 0000000..4a763b1 --- /dev/null +++ b/XIVChatPlugin/PluginUI.cs @@ -0,0 +1,345 @@ +using ImGuiNET; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Numerics; +using System.Threading.Channels; + +namespace XIVChatPlugin { + public class PluginUI { + private readonly Plugin plugin; + + private bool _showSettings = false; + public bool ShowSettings { get => this._showSettings; set => this._showSettings = value; } + + private readonly Dictionary>> pending = new Dictionary>>(); + private readonly Dictionary pendingNames = new Dictionary(0); + + public PluginUI(Plugin plugin) { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null"); + } + + private static class Colours { + public static readonly Vector4 primary = new Vector4(2 / 255f, 204 / 255f, 238 / 255f, 1.0f); + public static readonly Vector4 primaryDark = new Vector4(2 / 255f, 180 / 255f, 211 / 255f, 1.0f); + public static readonly Vector4 background = new Vector4(46 / 255f, 46 / 255f, 46 / 255f, 1.0f); + public static readonly Vector4 text = new Vector4(190 / 255f, 190 / 255f, 190 / 255f, 1.0f); + public static readonly Vector4 button = new Vector4(90 / 255f, 89 / 255f, 90 / 255f, 1.0f); + public static readonly Vector4 buttonActive = new Vector4(123 / 255f, 122 / 255f, 124 / 255f, 1.0f); + public static readonly Vector4 buttonHovered = new Vector4(108 / 255f, 107 / 255f, 109 / 255f, 1.0f); + + public static readonly Vector4 white = new Vector4(1f, 1f, 1f, 1f); + } + + public void Draw() { + ImGui.PushStyleColor(ImGuiCol.TitleBg, Colours.primaryDark); + ImGui.PushStyleColor(ImGuiCol.TitleBgActive, Colours.primary); + ImGui.PushStyleColor(ImGuiCol.TitleBgCollapsed, Colours.primaryDark); + ImGui.PushStyleColor(ImGuiCol.WindowBg, Colours.background); + ImGui.PushStyleColor(ImGuiCol.Text, Colours.text); + ImGui.PushStyleColor(ImGuiCol.Button, Colours.button); + ImGui.PushStyleColor(ImGuiCol.ButtonActive, Colours.buttonActive); + ImGui.PushStyleColor(ImGuiCol.ButtonHovered, Colours.buttonHovered); + + this.DrawInner(); + + ImGui.PopStyleColor(8); + } + + private static V WithWhiteText(Func func) { + ImGui.PushStyleColor(ImGuiCol.Text, Colours.white); + var ret = func(); + ImGui.PopStyleColor(); + return ret; + } + + private static void WithWhiteText(Action func) { + ImGui.PushStyleColor(ImGuiCol.Text, Colours.white); + func(); + ImGui.PopStyleColor(); + } + + private static bool Begin(string name, ImGuiWindowFlags flags) { + return WithWhiteText(() => ImGui.Begin(name, flags)); + } + + private static bool Begin(string name, ref bool showSettings, ImGuiWindowFlags flags) { + ImGui.PushStyleColor(ImGuiCol.Text, Colours.white); + var result = ImGui.Begin(name, ref showSettings, flags); + ImGui.PopStyleColor(); + return result; + } + + private static void TextWhite(string text) => WithWhiteText(() => ImGui.TextUnformatted(text)); + + private void DrawInner() { + this.AcceptPending(); + + foreach (var item in this.pending.ToList()) { + if (this.DrawPending(item.Key, item.Value.Item1, item.Value.Item2)) { + this.pending.Remove(item.Key); + } + } + + if (!this.ShowSettings || !Begin(this.plugin.Name, ref this._showSettings, ImGuiWindowFlags.AlwaysAutoResize)) { + return; + } + + if (WithWhiteText(() => ImGui.CollapsingHeader("Server public key"))) { + string serverPublic = this.plugin.Config.KeyPair.PublicKey.ToHexString(upper: true); + ImGui.TextUnformatted(serverPublic); + this.DrawColours(this.plugin.Config.KeyPair.PublicKey, serverPublic); + + if (WithWhiteText(() => ImGui.Button("Regenerate"))) { + this.plugin.Server.RegenerateKeyPair(); + } + } + + if (WithWhiteText(() => ImGui.CollapsingHeader("Settings", ImGuiTreeNodeFlags.DefaultOpen))) { + TextWhite("Port"); + + int port = this.plugin.Config.Port; + if (WithWhiteText(() => ImGui.InputInt("##port", ref port))) { + ushort realPort = (ushort)Math.Min(ushort.MaxValue, Math.Max(1, port)); + this.plugin.Config.Port = realPort; + this.plugin.Config.Save(); + } + + ImGui.Spacing(); + + bool backlogEnabled = this.plugin.Config.BacklogEnabled; + if (WithWhiteText(() => ImGui.Checkbox("Enable backlog", ref backlogEnabled))) { + this.plugin.Config.BacklogEnabled = backlogEnabled; + this.plugin.Config.Save(); + } + + int backlogCount = this.plugin.Config.BacklogCount; + if (WithWhiteText(() => ImGui.DragInt("Backlog messages", ref backlogCount, 1f, 0, ushort.MaxValue))) { + this.plugin.Config.BacklogCount = (ushort)Math.Max(0, Math.Min(ushort.MaxValue, backlogCount)); + this.plugin.Config.Save(); + } + + ImGui.Spacing(); + + bool sendBattle = this.plugin.Config.SendBattle; + if (WithWhiteText(() => ImGui.Checkbox("Send battle messages", ref sendBattle))) { + this.plugin.Config.SendBattle = sendBattle; + this.plugin.Config.Save(); + } + + ImGui.TextUnformatted("Changing this setting will not affect messages already in the backlog."); + } + + if (WithWhiteText(() => ImGui.CollapsingHeader("Trusted keys"))) { + if (this.plugin.Config.TrustedKeys.Count == 0) { + ImGui.TextUnformatted("None"); + } + + ImGui.Columns(2); + var maxKeyLength = 0f; + foreach (var entry in this.plugin.Config.TrustedKeys.ToList()) { + var name = entry.Value.Item1; + + var key = entry.Value.Item2; + var hex = key.ToHexString(upper: true); + + maxKeyLength = Math.Max(maxKeyLength, ImGui.CalcTextSize(name).X); + + ImGui.TextUnformatted(name); + if (ImGui.IsItemHovered()) { + ImGui.BeginTooltip(); + ImGui.TextUnformatted(hex); + this.DrawColours(key, hex); + ImGui.EndTooltip(); + } + ImGui.NextColumn(); + + if (WithWhiteText(() => ImGui.Button($"Untrust##{entry.Key}"))) { + this.plugin.Config.TrustedKeys.Remove(entry.Key); + this.plugin.Config.Save(); + } + ImGui.NextColumn(); + } + ImGui.SetColumnWidth(0, maxKeyLength + ImGui.GetStyle().ItemSpacing.X * 2); + ImGui.Columns(1); + } + + + if (WithWhiteText(() => ImGui.CollapsingHeader("Connected clients"))) { + if (this.plugin.Server.Clients.Count == 0) { + ImGui.TextUnformatted("None"); + } else { + ImGui.Columns(3); + + TextWhite("IP"); + ImGui.NextColumn(); + TextWhite("Key"); + ImGui.NextColumn(); + ImGui.NextColumn(); + + foreach (var client in this.plugin.Server.Clients) { + EndPoint remote; + try { + remote = client.Value.Conn.Client.RemoteEndPoint; + } catch (ObjectDisposedException) { + continue; + } + string ipAddress; + if (remote is IPEndPoint ip) { + ipAddress = ip.Address.ToString(); + } else { + ipAddress = "Unknown"; + } + ImGui.TextUnformatted(ipAddress); + + ImGui.NextColumn(); + + var trustedKey = this.plugin.Config.TrustedKeys.Values.FirstOrDefault(entry => entry.Item2.SequenceEqual(client.Value.Handshake.RemotePublicKey)); + if (trustedKey != default(Tuple)) { + ImGui.TextUnformatted(trustedKey.Item1); + if (ImGui.IsItemHovered()) { + ImGui.BeginTooltip(); + + var hex = trustedKey.Item2.ToHexString(upper: true); + ImGui.TextUnformatted(hex); + this.DrawColours(trustedKey.Item2, hex); + + ImGui.EndTooltip(); + } + } + ImGui.NextColumn(); + + if (WithWhiteText(() => ImGui.Button($"Disconnect##{client.Key}"))) { + client.Value.Disconnect(); + } + + ImGui.NextColumn(); + } + ImGui.Columns(1); + } + } + + if (WithWhiteText(() => ImGui.CollapsingHeader("ACT/Teamcraft issues?"))) { + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 20); + ImGui.TextUnformatted("Click on the button below to visit a website showing a workaround for ACT and Teamcraft having issues."); + ImGui.PopTextWrapPos(); + + if (WithWhiteText(() => ImGui.Button("Open website"))) { + System.Diagnostics.Process.Start("https://xiv.chat/server/workaround"); + } + } + + ImGui.End(); + } + + private void DrawColours(byte[] bytes, string widthOf) { + this.DrawColours(bytes, ImGui.CalcTextSize(widthOf).X); + } + + private void DrawColours(byte[] bytes, float width = 0f) { + var pos = ImGui.GetCursorScreenPos(); + var spacing = ImGui.GetStyle().ItemSpacing; + + var colours = bytes.ToColours(); + + float sizeX = width == 0f ? 32f : width / colours.Count; + + for (int i = 0; i < colours.Count; i++) { + var topLeft = new Vector2( + pos.X + (sizeX * i), + pos.Y + spacing.Y + ); + var bottomRight = new Vector2( + pos.X + (sizeX * (i + 1)), + pos.Y + spacing.Y + 16 + ); + + ImGui.GetWindowDrawList().AddRectFilled( + topLeft, + bottomRight, + ImGui.GetColorU32(colours[i]) + ); + } + + // create a spacing for 32px and spacing + ImGui.Dummy(new Vector2(0, 16 + spacing.Y * 2)); + } + + public void OpenSettings(object sender, EventArgs args) { + this.ShowSettings = true; + } + + private void AcceptPending() { + while (this.plugin.Server.pendingClients.Reader.TryRead(out var item)) { + this.pending[Guid.NewGuid()] = item; + } + } + + private bool DrawPending(Guid id, Client client, Channel accepted) { + bool ret = false; + + var clientPublic = client.Handshake.RemotePublicKey; + var clientPublicHex = clientPublic.ToHexString(upper: true); + var serverPublic = this.plugin.Config.KeyPair.PublicKey; + var serverPublicHex = serverPublic.ToHexString(upper: true); + + var width = Math.Max(ImGui.CalcTextSize(clientPublicHex).X, ImGui.CalcTextSize(serverPublicHex).X) + (ImGui.GetStyle().WindowPadding.X * 2); + + if (!Begin($"Incoming XIVChat connection##{clientPublic}", ImGuiWindowFlags.AlwaysAutoResize)) { + return ret; + } + + ImGui.PushTextWrapPos(width); + + ImGui.TextUnformatted("A client that has not previously connected is attempting to connect to XIVChat. If this is you, please check the two keys below and make sure that they match what is displayed by the client."); + + ImGui.Separator(); + + TextWhite("Server"); + ImGui.TextUnformatted(serverPublicHex); + this.DrawColours(serverPublic, serverPublicHex); + + ImGui.Spacing(); + + TextWhite("Client"); + ImGui.TextUnformatted(clientPublicHex); + this.DrawColours(clientPublic, clientPublicHex); + + ImGui.Separator(); + + ImGui.TextUnformatted("Give this client a name to remember it more easily if you trust it."); + + ImGui.PopTextWrapPos(); + + if (!this.pendingNames.TryGetValue(id, out string name)) { + name = "No name"; + } + + if (WithWhiteText(() => ImGui.InputText("Client name", ref name, 100, ImGuiInputTextFlags.AutoSelectAll))) { + this.pendingNames[id] = name; + } + + ImGui.Separator(); + + ImGui.TextUnformatted("Do both keys match?"); + if (WithWhiteText(() => ImGui.Button("Yes"))) { + accepted.Writer.TryWrite(true); + this.plugin.Config.TrustedKeys[Guid.NewGuid()] = Tuple.Create(name, client.Handshake.RemotePublicKey); + this.plugin.Config.Save(); + this.pendingNames.Remove(id); + ret = true; + } + ImGui.SameLine(); + if (WithWhiteText(() => ImGui.Button("No"))) { + accepted.Writer.TryWrite(false); + this.pendingNames.Remove(id); + ret = true; + } + + ImGui.End(); + + return ret; + } + } +} diff --git a/XIVChatPlugin/Properties/AssemblyInfo.cs b/XIVChatPlugin/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..3433a3f --- /dev/null +++ b/XIVChatPlugin/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 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("XIVChatPlugin")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("XIVChatPlugin")] +[assembly: AssemblyCopyright("Copyright © 2020")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("9aeb2b77-c127-4ba8-b9ae-fb3ca1649fdb")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0")] +[assembly: AssemblyFileVersion("1.0.0")] diff --git a/XIVChatPlugin/Resources/lib/libsodium.dll b/XIVChatPlugin/Resources/lib/libsodium.dll new file mode 100644 index 0000000..4202c3a Binary files /dev/null and b/XIVChatPlugin/Resources/lib/libsodium.dll differ diff --git a/XIVChatPlugin/Server.cs b/XIVChatPlugin/Server.cs new file mode 100644 index 0000000..b475bdc --- /dev/null +++ b/XIVChatPlugin/Server.cs @@ -0,0 +1,769 @@ +using Dalamud.Game.Chat; +using Dalamud.Game.Chat.SeStringHandling; +using Dalamud.Game.Chat.SeStringHandling.Payloads; +using Dalamud.Game.Internal; +using Dalamud.Plugin; +using Lumina.Data; +using Lumina.Excel; +using Lumina.Excel.GeneratedSheets; +using MessagePack; +using Sodium; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using XIVChatCommon; + +namespace XIVChatPlugin { + public class Server : IDisposable { + private readonly Plugin plugin; + + private readonly CancellationTokenSource tokenSource = new CancellationTokenSource(); + private readonly ConcurrentQueue toGame = new ConcurrentQueue(); + + private readonly ConcurrentDictionary clients = new ConcurrentDictionary(); + public IReadOnlyDictionary Clients => this.clients; + public readonly Channel>> pendingClients = Channel.CreateUnbounded>>(); + + private readonly HashSet WaitingForFriendList = new HashSet(); + + private readonly LinkedList Backlog = new LinkedList(); + + private TcpListener listener; + + private bool sendPlayerData = false; + + private volatile bool _running = false; + public bool Running { get => this._running; set => this._running = value; } + + private InputChannel currentChannel = InputChannel.Say; + + private const int MAX_MESSAGE_SIZE = 128_000; + + public Server(Plugin plugin) { + this.plugin = plugin ?? throw new ArgumentNullException(nameof(plugin), "Plugin cannot be null"); + if (this.plugin.Config.KeyPair == null) { + this.RegenerateKeyPair(); + } + + this.plugin.Functions.ReceiveFriendList += this.OnReceiveFriendList; + } + + private async void OnReceiveFriendList(List friends) { + var msg = new ServerPlayerList { + Type = PlayerListType.Friend, + Players = friends.ToArray(), + }; + + foreach (var id in this.WaitingForFriendList) { + if (!this.Clients.TryGetValue(id, out var client)) { + continue; + } + + try { + await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, msg, client.TokenSource.Token); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + } + + this.WaitingForFriendList.Clear(); + } + + public void Spawn() { + var ip = IPAddress.Parse("0.0.0.0"); + var port = this.plugin.Config.Port; + + Task.Run(async () => { + this.listener = new TcpListener(ip, port); + this.listener.Start(); + + this._running = true; + PluginLog.Log("Running..."); + while (!this.tokenSource.IsCancellationRequested) { + var conn = await this.listener.GetTcpClient(this.tokenSource); + this.SpawnClientTask(conn); + } + this._running = false; + }); + } + + public void RegenerateKeyPair() { + this.plugin.Config.KeyPair = PublicKeyBox.GenerateKeyPair(); + this.plugin.Config.Save(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "delegate")] + public void OnChat(XivChatType type, uint senderId, ref SeString sender, ref SeString message, ref bool isHandled) { + if (isHandled) { + return; + } + + var chatCode = new ChatCode((ushort)type); + + if (!this.plugin.Config.SendBattle && chatCode.IsBattle()) { + return; + } + + var chunks = new List(); + + if (sender.Payloads.Count > 0) { + // FIXME: can't get format straight from game until Lumina stops returning LogKind.Format as a string (it's an SeString) + // var format = this.FormatFor(chatCode.Type); + var format = chatCode.NameFormat(); + if (format != null && format.IsPresent) { + chunks.Add(new TextChunk { + FallbackColour = chatCode.DefaultColour(), + Content = format.Before, + }); + chunks.AddRange(ToChunks(sender, chatCode.DefaultColour())); + chunks.Add(new TextChunk { + FallbackColour = chatCode.DefaultColour(), + Content = format.After, + }); + } + } + + chunks.AddRange(ToChunks(message, chatCode.DefaultColour())); + + var msg = new ServerMessage { + Timestamp = DateTime.UtcNow, + Channel = (ChatType)type, + Sender = sender.Encode(), + Content = message.Encode(), + Chunks = chunks, + }; + + this.Backlog.AddLast(msg); + while (this.Backlog.Count > this.plugin.Config.BacklogCount) { + this.Backlog.RemoveFirst(); + } + + foreach (var client in this.clients.Values) { + client.Queue.Writer.TryWrite(msg); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "delegate")] + public void OnFrameworkUpdate(Framework framework) { + if (this.sendPlayerData && this.plugin.Interface.ClientState.LocalPlayer != null) { + this.BroadcastPlayerData(); + this.sendPlayerData = false; + } + + if (!this.toGame.TryDequeue(out var message)) { + return; + } + + this.plugin.Functions.ProcessChatBox(message); + } + + private void SpawnClientTask(TcpClient conn) { + if (conn == null) { + return; + } + + Task.Run(async () => { + var stream = conn.GetStream(); + + var handshake = await KeyExchange.ServerHandshake(this.plugin.Config.KeyPair, stream); + var newClient = new Client(conn) { + Handshake = handshake, + }; + + // if this public key isn't trusted, prompt first + if (!this.plugin.Config.TrustedKeys.Values.Any(entry => entry.Item2.SequenceEqual(handshake.RemotePublicKey))) { + var accepted = Channel.CreateBounded(1); + + await this.pendingClients.Writer.WriteAsync(Tuple.Create(newClient, accepted)); + if (!await accepted.Reader.ReadAsync()) { + return; + } + } + + var id = Guid.NewGuid(); + newClient.Connected = true; + this.clients[id] = newClient; + + // send availability + var available = this.plugin.Interface.ClientState.LocalPlayer != null; + try { + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, new Availability(available)); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + + // send player data + try { + await this.SendPlayerData(newClient); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + + // send current channel + try { + var channel = this.currentChannel; + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, new ServerChannel( + channel, + this.LocalisedChannelName(channel) + )); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + + var listen = Task.Run(async () => { + conn.ReceiveTimeout = 5_000; + + while (this.clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested && conn.Connected) { + byte[] msg; + try { + msg = await SecretMessage.ReadSecretMessage(stream, handshake.Keys.rx, client.TokenSource.Token); + } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut) { + continue; + } catch (Exception ex) { + PluginLog.LogError($"Could not read message: {ex.Message}"); + continue; + } + + var op = (ClientOperation)msg[0]; + + var payload = new byte[msg.Length - 1]; + Array.Copy(msg, 1, payload, 0, payload.Length); + + switch (op) { + case ClientOperation.Ping: + try { + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, Pong.Instance, client.TokenSource.Token); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + break; + case ClientOperation.Message: + var clientMessage = ClientMessage.Decode(payload); + foreach (var part in Wrap(clientMessage.Content)) { + this.toGame.Enqueue(part); + } + break; + case ClientOperation.Shutdown: + client.Disconnect(); + break; + case ClientOperation.Backlog: + var backlog = ClientBacklog.Decode(payload); + + var backlogMessages = new List(); + + var node = this.Backlog.Last; + while (node != null) { + if (backlogMessages.Count >= backlog.Amount) { + break; + } + + backlogMessages.Add(node.Value); + node = node.Previous; + } + + backlogMessages.Reverse(); + + await this.SendBacklogs(backlogMessages.ToArray(), stream, client); + break; + case ClientOperation.CatchUp: + var catchUp = ClientCatchUp.Decode(payload); + // I'm not sure why this needs to be done, but apparently it does + var after = catchUp.After.AddMilliseconds(1); + var msgs = this.MessagesAfter(after); + + await this.SendBacklogs(msgs, stream, client); + break; + case ClientOperation.PlayerList: + var playerList = ClientPlayerList.Decode(payload); + + if (playerList.Type == PlayerListType.Friend) { + this.WaitingForFriendList.Add(id); + + if (!this.plugin.Functions.RequestingFriendList && !this.plugin.Functions.RequestFriendList()) { + this.plugin.Interface.Framework.Gui.Chat.PrintError($"[{this.plugin.Name}] Please open your friend list to enable friend list support. You should only need to do this on initial install or after updates."); + } + } + + break; + } + } + }); + + while (this.clients.TryGetValue(id, out var client) && client.Connected && !client.TokenSource.IsCancellationRequested && conn.Connected) { + try { + var msg = await client.Queue.Reader.ReadAsync(client.TokenSource.Token); + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, msg, client.TokenSource.Token); + } catch (Exception ex) { + PluginLog.LogError($"Could not send message: {ex.Message}"); + } + } + + try { + conn.Close(); + } catch (ObjectDisposedException) { } + + await listen; + + this.clients.TryRemove(id, out var _); + PluginLog.Log($"Client thread ended: {id}"); + }); + } + + private static readonly Regex colorRegex = new Regex(@"", RegexOptions.Compiled); + private Dictionary Formats { get; } = new Dictionary(); + + [Sheet("LogKind")] + class LogKind : IExcelRow { + public byte Unknown0; + public byte[] Format; + public bool Unknown2; + + public uint RowId { get; set; } + public uint SubRowId { get; set; } + + public void PopulateData(RowParser parser, Lumina.Lumina lumina, Language language) { + this.RowId = parser.Row; + this.SubRowId = parser.SubRow; + + this.Unknown0 = parser.ReadColumn(0); + this.Format = parser.ReadColumn(1); + this.Unknown2 = parser.ReadColumn(2); + } + } + + private NameFormatting FormatFor(ChatType type) { + if (this.Formats.TryGetValue(type, out var cached)) { + return cached; + } + + var logKind = this.plugin.Interface.Data.GetExcelSheet().GetRow((ushort)type); + + if (logKind == null) { + return null; + } + + var sestring = this.plugin.Interface.SeStringManager.Parse(logKind.Format); + + //PluginLog.Log(string.Join("", Encoding.ASCII.GetBytes(logKind.Format).Select(b => b.ToString("x2")))); + + //var sestring = this.plugin.Interface.SeStringManager.Parse(Encoding.ASCII.GetBytes(logKind.Format)); + + //var format = colorRegex.Replace(logKind.Format, "") + // .Replace("", "") + // .Replace("", "") + // .Replace("", ""); + + return NameFormatting.Empty(); + + //if (format.IndexOf("StringParameter(1)") == -1) { + // return NameFormatting.Empty(); + //} + + //var parts = format.Split(new string[] { "StringParameter(1)" }, StringSplitOptions.None); + + //var nameFormatting = NameFormatting.Of( + // before: parts[0], + // after: parts[1].Split(new string[] { "StringParameter(2)" }, StringSplitOptions.None)[0] + //); + + //this.Formats[type] = nameFormatting; + + //return nameFormatting; + } + + private async Task SendBacklogs(ServerMessage[] messages, NetworkStream stream, Client client) { + var size = 5 + SecretMessage.MacSize(); // assume 5 bytes for payload lead-in, although it's likely to be less + var responseMessages = new List(); + + async Task SendBacklog() { + var resp = new ServerBacklog(responseMessages.ToArray()); + try { + await SecretMessage.SendSecretMessage(stream, client.Handshake.Keys.tx, resp, client.TokenSource.Token); + } catch (Exception ex) { + PluginLog.LogError($"Could not send backlog: {ex.Message}"); + } + } + + foreach (var catchUpMessage in messages) { + // FIXME: this is very gross + var len = MessagePackSerializer.Serialize(catchUpMessage).Length; + // send message if it would've gone over length + if (size + len >= MAX_MESSAGE_SIZE) { + await SendBacklog(); + + size = 5 + SecretMessage.MacSize(); + responseMessages.Clear(); + + } + size += len; + responseMessages.Add(catchUpMessage); + } + + if (responseMessages.Count > 0) { + await SendBacklog(); + } + } + + private static List ToChunks(SeString msg, uint? defaultColour) { + var chunks = new List(); + + var italic = false; + var foreground = new Stack(); + var glow = new Stack(); + + void Append(string text) { + chunks.Add(new TextChunk { + FallbackColour = defaultColour, + Foreground = foreground.Count > 0 ? foreground.Peek() : (uint?)null, + Glow = glow.Count > 0 ? glow.Peek() : (uint?)null, + Italic = italic, + Content = text, + }); + } + + foreach (var payload in msg.Payloads) { + switch (payload.Type) { + case PayloadType.EmphasisItalic: + var newStatus = ((EmphasisItalicPayload)payload).IsEnabled; + italic = newStatus; + break; + case PayloadType.UIForeground: + var foregroundPayload = (UIForegroundPayload)payload; + if (foregroundPayload.IsEnabled) { + foreground.Push(foregroundPayload.UIColor.UIForeground); + } else if (foreground.Count > 0) { + foreground.Pop(); + } + break; + case PayloadType.UIGlow: + var glowPayload = (UIGlowPayload)payload; + if (glowPayload.IsEnabled) { + glow.Push(glowPayload.UIColor.UIGlow); + } else if (glow.Count > 0) { + glow.Pop(); + } + break; + case PayloadType.AutoTranslateText: + chunks.Add(new IconChunk { Index = 54 }); + var autoText = ((AutoTranslatePayload)payload).Text; + Append(autoText.Substring(2, autoText.Length - 4)); + chunks.Add(new IconChunk { Index = 55 }); + break; + case PayloadType.Icon: + var index = ((IconPayload)payload).IconIndex; + chunks.Add(new IconChunk { Index = (byte)index }); + break; + // FIXME: use ITextProvider directly once it's exposed + case PayloadType.RawText: + Append(((TextPayload)payload).Text); + break; + case PayloadType.Unknown: + var rawPayload = (RawPayload)payload; + if (rawPayload.Data[1] == 0x13) { + foreground.Pop(); + glow.Pop(); + } + break; + //default: + // var textProviderType = typeof(SeString).Assembly.GetType("Dalamud.Game.Chat.SeStringHandling.ITextProvider"); + // var textProp = textProviderType.GetProperty("Text", BindingFlags.NonPublic | BindingFlags.Instance); + // var text = (string)textProp.GetValue(payload); + // append(text); + // break; + } + } + + return chunks; + } + + private ServerMessage[] MessagesAfter(DateTime time) => this.Backlog.Where(msg => msg.Timestamp > time).ToArray(); + + private static string[] Wrap(string input) { + const int LIMIT = 500; + + if (input.Length <= LIMIT) { + return new string[] { input }; + } + + string prefix = string.Empty; + if (input.StartsWith("/")) { + var space = input.IndexOf(' '); + if (space != -1) { + prefix = input.Substring(0, space); + input = input.Substring(space + 1); + } + } + + var parts = new List(); + + var builder = new StringBuilder(LIMIT); + + foreach (var word in input.Split(' ')) { + if (word.Length > LIMIT) { + int wordParts = (int)Math.Ceiling((float)word.Length / LIMIT); + for (int i = 0; i < wordParts; i++) { + var start = i == 0 ? 0 : (i * LIMIT); + var partLength = LIMIT; + if (prefix.Length != 0) { + start = start == 0 ? 0 : (start - (prefix.Length + 1) * i); + partLength = partLength - prefix.Length - 1; + } + + var part = word.Length - start < partLength ? word.Substring(start) : word.Substring(start, partLength); + if (part.Length == 0) { + continue; + } + + if (prefix.Length != 0) { + part = prefix + " " + part; + } + + parts.Add(part); + } + continue; + } + + if (builder.Length + word.Length > LIMIT) { + parts.Add(builder.ToString().TrimEnd(' ')); + builder.Clear(); + } + + if (builder.Length == 0 && prefix.Length != 0) { + builder.Append(prefix); + builder.Append(' '); + } + + builder.Append(word); + builder.Append(' '); + } + + if (builder.Length != 0) { + parts.Add(builder.ToString().TrimEnd(' ')); + } + + return parts.ToArray(); + } + + private void BroadcastMessage(IEncodable message) { + foreach (var client in this.Clients.Values) { + if (client.Handshake == null || client.Conn == null) { + continue; + } + + Task.Run(async () => { + await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, message); + }); + } + } + + private string LocalisedChannelName(InputChannel channel) { + uint rowId; + switch (channel) { + case InputChannel.Tell: + rowId = 3; + break; + case InputChannel.Say: + rowId = 1; + break; + case InputChannel.Party: + rowId = 4; + break; + case InputChannel.Alliance: + rowId = 17; + break; + case InputChannel.Yell: + rowId = 16; + break; + case InputChannel.Shout: + rowId = 2; + break; + case InputChannel.FreeCompany: + rowId = 7; + break; + case InputChannel.PvpTeam: + rowId = 19; + break; + case InputChannel.NoviceNetwork: + rowId = 18; + break; + case InputChannel.CrossLinkshell1: + rowId = 20; + break; + case InputChannel.CrossLinkshell2: + rowId = 300; + break; + case InputChannel.CrossLinkshell3: + rowId = 301; + break; + case InputChannel.CrossLinkshell4: + rowId = 302; + break; + case InputChannel.CrossLinkshell5: + rowId = 303; + break; + case InputChannel.CrossLinkshell6: + rowId = 304; + break; + case InputChannel.CrossLinkshell7: + rowId = 305; + break; + case InputChannel.CrossLinkshell8: + rowId = 306; + break; + case InputChannel.Linkshell1: + rowId = 8; + break; + case InputChannel.Linkshell2: + rowId = 9; + break; + case InputChannel.Linkshell3: + rowId = 10; + break; + case InputChannel.Linkshell4: + rowId = 11; + break; + case InputChannel.Linkshell5: + rowId = 12; + break; + case InputChannel.Linkshell6: + rowId = 13; + break; + case InputChannel.Linkshell7: + rowId = 14; + break; + case InputChannel.Linkshell8: + rowId = 15; + break; + default: + rowId = 0; + break; + }; + return this.plugin.Interface.Data.GetExcelSheet().GetRow(rowId).Name; + } + + public void OnChatChannelChange(uint channel) { + var inputChannel = (InputChannel)channel; + this.currentChannel = inputChannel; + + var localisedName = this.LocalisedChannelName(inputChannel); + + var msg = new ServerChannel(inputChannel, localisedName); + this.BroadcastMessage(msg); + } + + private void BroadcastAvailability(bool available) { + this.BroadcastMessage(new Availability(available)); + } + + private PlayerData GeneratePlayerData() { + var player = this.plugin.Interface.ClientState.LocalPlayer; + if (player == null) { + return null; + } + + var homeWorld = player.HomeWorld.GameData.Name; + var currentWorld = player.CurrentWorld.GameData.Name; + var territory = this.plugin.Interface.Data.GetExcelSheet().GetRow(this.plugin.Interface.ClientState.TerritoryType); + var location = territory.PlaceName.Value.Name; + var name = player.Name; + + return new PlayerData(homeWorld, currentWorld, location, name); + } + + private async Task SendPlayerData(Client client) { + var playerData = this.GeneratePlayerData(); + if (playerData == null) { + return; + } + await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, playerData); + } + + private void BroadcastPlayerData() { + var playerData = this.GeneratePlayerData(); + + if (playerData == null) { + this.BroadcastMessage(EmptyPlayerData.Instance); + return; + } + + this.BroadcastMessage(playerData); + } + + public void OnLogIn(object sender, EventArgs e) { + this.BroadcastAvailability(true); + // send player data on next framework update + this.sendPlayerData = true; + } + + public void OnLogOut(object sender, EventArgs e) { + this.BroadcastAvailability(false); + this.BroadcastPlayerData(); + } + + public void OnTerritoryChange(object sender, ushort territoryId) => this.BroadcastPlayerData(); + + public void Dispose() { + // stop accepting new clients + this.tokenSource.Cancel(); + foreach (var client in this.clients.Values) { + Task.Run(async () => { + // tell clients we're shutting down + if (client.Handshake != null) { + try { + // time out after 5 seconds + client.Conn.SendTimeout = 5_000; + await SecretMessage.SendSecretMessage(client.Conn.GetStream(), client.Handshake.Keys.tx, ServerShutdown.Instance); + } catch (Exception) { } + } + // cancel threads for open clients + client.TokenSource.Cancel(); + }); + } + this.plugin.Functions.ReceiveFriendList -= this.OnReceiveFriendList; + } + } + + public class Client { + public bool Connected { get; set; } = false; + public TcpClient Conn { get; private set; } + public HandshakeInfo Handshake { get; set; } + public CancellationTokenSource TokenSource { get; } = new CancellationTokenSource(); + public Channel Queue { get; } = Channel.CreateUnbounded(); + + public Client(TcpClient conn) { + this.Conn = conn; + } + + public void Disconnect() { + this.Connected = false; + this.TokenSource.Cancel(); + this.Conn.Close(); + } + } + + internal static class TcpListenerExt { + public static async Task GetTcpClient(this TcpListener listener, CancellationTokenSource source) { + using (source.Token.Register(listener.Stop)) { + try { + var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false); + return client; + } catch (ObjectDisposedException ex) { + // Token was canceled - swallow the exception and return null + if (source.Token.IsCancellationRequested) { + return null; + } + + throw ex; + } + } + } + } +} diff --git a/XIVChatPlugin/XIVChatPlugin.csproj b/XIVChatPlugin/XIVChatPlugin.csproj new file mode 100644 index 0000000..1086093 --- /dev/null +++ b/XIVChatPlugin/XIVChatPlugin.csproj @@ -0,0 +1,137 @@ + + + + + + + Debug + AnyCPU + {9AEB2B77-C127-4BA8-B9AE-FB3CA1649FDB} + Library + Properties + XIVChatPlugin + XIVChatPlugin + v4.8 + 512 + true + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + False + + + %appdata%\XIVLauncher\addon\Hooks\ImGui.NET.dll + False + + + %appdata%\XIVLauncher\addon\Hooks\ImGuiScene.dll + False + + + %appdata%\XIVLauncher\addon\Hooks\Lumina.dll + False + + + %appdata%\XIVLauncher\addon\Hooks\Lumina.Generated.dll + False + + + ..\packages\MessagePack.2.1.194\lib\netstandard2.0\MessagePack.dll + + + ..\packages\MessagePack.Annotations.2.1.194\lib\netstandard2.0\MessagePack.Annotations.dll + + + ..\packages\Microsoft.Bcl.AsyncInterfaces.1.0.0\lib\net461\Microsoft.Bcl.AsyncInterfaces.dll + + + ..\packages\Sodium.Core.1.2.3\lib\netstandard2.0\Sodium.Core.dll + + + + ..\packages\System.Buffers.4.4.0\lib\netstandard2.0\System.Buffers.dll + + + + ..\packages\System.Memory.4.5.3\lib\netstandard2.0\System.Memory.dll + + + + ..\packages\System.Numerics.Vectors.4.4.0\lib\net46\System.Numerics.Vectors.dll + + + ..\packages\System.Runtime.CompilerServices.Unsafe.4.7.1\lib\net461\System.Runtime.CompilerServices.Unsafe.dll + + + ..\packages\System.Threading.Channels.4.7.1\lib\net461\System.Threading.Channels.dll + + + ..\packages\System.Threading.Tasks.Extensions.4.5.4\lib\net461\System.Threading.Tasks.Extensions.dll + False + + + ..\packages\System.ValueTuple.4.5.0\lib\net47\System.ValueTuple.dll + + + + + + + + + + + + + + + + + + + + + + + + + + {6f426eab-15d4-48ac-80d2-2d2df2ce5ee0} + XIVChatCommon + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + \ No newline at end of file diff --git a/XIVChatPlugin/XIVChatPlugin.json b/XIVChatPlugin/XIVChatPlugin.json new file mode 100644 index 0000000..022a872 --- /dev/null +++ b/XIVChatPlugin/XIVChatPlugin.json @@ -0,0 +1,9 @@ +{ + "Author": "ascclemens", + "Name": "XIVChat Server", + "Description": "Allows you to use various XIVChat clients to chat in FFXIV from anywhere!", + "InternalName": "XIVChatPlugin", + "AssemblyVersion": "1.0.0", + "ApplicableVersion": "any", + "DalamudApiLevel": 1 +} diff --git a/XIVChatPlugin/app.config b/XIVChatPlugin/app.config new file mode 100644 index 0000000..e3c9220 --- /dev/null +++ b/XIVChatPlugin/app.config @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChatPlugin/packages.config b/XIVChatPlugin/packages.config new file mode 100644 index 0000000..881c6d5 --- /dev/null +++ b/XIVChatPlugin/packages.config @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/logo-no-background.svg b/logo-no-background.svg new file mode 100644 index 0000000..6081f3c --- /dev/null +++ b/logo-no-background.svg @@ -0,0 +1,84 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + XV + | + + diff --git a/logo-no-outer-background.svg b/logo-no-outer-background.svg new file mode 100644 index 0000000..c566129 --- /dev/null +++ b/logo-no-outer-background.svg @@ -0,0 +1,122 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/logo-plain.svg b/logo-plain.svg new file mode 100644 index 0000000..5d9a454 --- /dev/null +++ b/logo-plain.svg @@ -0,0 +1,83 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + XV + | + + diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..d07795c --- /dev/null +++ b/logo.svg @@ -0,0 +1,121 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + XV + | + +