From 81f70d07c243c8a9af4e4983d9f1b9c83c405546 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 11:17:50 +0900 Subject: [PATCH 01/31] refactor: update FNA framework submodules, dependencies, and project configurations alongside engine-side feature additions --- Client/Client.csproj | 74 +- Client/Forms/AMain.cs | 11 +- Client/Forms/CMain.cs | 34 +- Client/KeyBindSettings.cs | 6 +- Client/MirControls/MirAmountBox.cs | 6 +- Client/MirControls/MirControl.cs | 85 +- Client/MirControls/MirImageControl.cs | 13 +- Client/MirControls/MirInputBox.cs | 6 +- Client/MirControls/MirLabel.cs | 242 ++- Client/MirControls/MirMessageBox.cs | 10 +- Client/MirControls/MirScene.cs | 18 +- Client/MirControls/MirScrollingLabel.cs | 13 +- Client/MirControls/MirTextBox.cs | 396 ++++- Client/MirGraphics/DXManager.cs | 212 ++- Client/MirGraphics/MLibrary.cs | 226 ++- Client/MirGraphics/ParticleEngine.cs | 6 +- Client/MirGraphics/Particles/Particle.cs | 6 +- Client/MirNetwork/Network.cs | 10 +- Client/MirObjects/MapCode.cs | 7 +- Client/MirScenes/Dialogs/GuildDialog.cs | 38 +- Client/MirScenes/Dialogs/InventoryDialog.cs | 9 +- Client/MirScenes/Dialogs/MainDialogs.cs | 18 +- Client/MirScenes/Dialogs/NPCDialogs.cs | 16 +- Client/MirScenes/Dialogs/NoticeDialog.cs | 17 +- Client/MirScenes/Dialogs/QuestDialogs.cs | 10 +- .../MirScenes/Dialogs/TrustMerchantDialog.cs | 4 +- Client/MirScenes/GameScene.cs | 424 +++++- Client/MirScenes/LoginScene.cs | 18 +- Client/MirScenes/SelectScene.cs | 6 +- Client/MirSounds/SoundManager.cs | 256 +++- Client/Platform/FNA/AssetResolver.cs | 153 ++ Client/Platform/FNA/FNAEntry.cs | 270 ++++ Client/Platform/FNA/FNAFontManager.cs | 81 + Client/Platform/FNA/FNARenderer.cs | 325 ++++ Client/Platform/FNA/FNATextRenderer.cs | 133 ++ Client/Platform/FNA/NativeFallbackSDL.cs | 93 ++ Client/Platform/FNA/ProgramFNA.cs | 71 + Client/Platform/GlobalUsings.cs | 19 + Client/Platform/IAssetResolver.cs | 13 + Client/Platform/IGraphicsRenderer.cs | 56 + Client/Platform/MirInputTypes.cs | 547 +++++++ .../MonoGameCompat/MonoGameCompat.csproj | 15 + .../Platform/MonoGameCompat/TypeForwarders.cs | 19 + Client/Program.cs | 5 +- Client/Resolution/DisplayResolutions.cs | 68 +- Client/Settings.cs | 82 +- Client/Utils/BrowserHelper.cs | 31 +- Client/Utils/HeadlessPatcher.cs | 682 +++++++++ Cross-platformPortingExperience.md | 167 ++ Legend of Mir.sln | 92 ++ Server.Headless/CommandProcessor.cs | 1341 +++++++++++++++++ Server.Headless/ConsoleServerHost.cs | 35 + Server.Headless/Program.cs | 71 + Server.Headless/README.md | 70 + Server.Headless/Server.Headless.csproj | 25 + Server.Headless/log4net.config | 77 + Server/IServerHost.cs | 9 + Server/MessageQueue.cs | 2 + Server/MirDatabase/MonsterInfo.cs | 4 +- Server/MirDatabase/QuestInfo.cs | 5 +- Server/MirEnvir/Envir.cs | 25 +- Server/MirEnvir/Map.cs | 6 +- Server/MirObjects/NPC/NPCScript.cs | 6 +- Server/MirObjects/NPC/NPCSegment.cs | 6 +- Server/Server.Library.csproj | 4 +- Server/Settings.cs | 4 +- Shared/Functions/Functions.cs | 46 +- Shared/Functions/IniReader.cs | 4 +- Shared/Shared.csproj | 2 +- 69 files changed, 6656 insertions(+), 205 deletions(-) create mode 100644 Client/Platform/FNA/AssetResolver.cs create mode 100644 Client/Platform/FNA/FNAEntry.cs create mode 100644 Client/Platform/FNA/FNAFontManager.cs create mode 100644 Client/Platform/FNA/FNARenderer.cs create mode 100644 Client/Platform/FNA/FNATextRenderer.cs create mode 100644 Client/Platform/FNA/NativeFallbackSDL.cs create mode 100644 Client/Platform/FNA/ProgramFNA.cs create mode 100644 Client/Platform/GlobalUsings.cs create mode 100644 Client/Platform/IAssetResolver.cs create mode 100644 Client/Platform/IGraphicsRenderer.cs create mode 100644 Client/Platform/MirInputTypes.cs create mode 100644 Client/Platform/MonoGameCompat/MonoGameCompat.csproj create mode 100644 Client/Platform/MonoGameCompat/TypeForwarders.cs create mode 100644 Client/Utils/HeadlessPatcher.cs create mode 100644 Cross-platformPortingExperience.md create mode 100644 Server.Headless/CommandProcessor.cs create mode 100644 Server.Headless/ConsoleServerHost.cs create mode 100644 Server.Headless/Program.cs create mode 100644 Server.Headless/README.md create mode 100644 Server.Headless/Server.Headless.csproj create mode 100644 Server.Headless/log4net.config create mode 100644 Server/IServerHost.cs diff --git a/Client/Client.csproj b/Client/Client.csproj index 773d0bbe1..936cf7743 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -1,30 +1,40 @@ - + WinExe - net8.0-windows7.0 + net10.0-windows;net10.0 disable - true enable MIR2.ICO True x64 ..\Build\Client\ + true + false - - false - false + + + true + WINDOWS;SLIMDX + false + false + + + + + false + FNA;LINUX - True - 1701;1702 + False + 1701;1702;CA1416 - True - 1701;1702 + False + 1701;1702;CA1416 @@ -35,20 +45,55 @@ + + + + + + + + + + + + + + + + + ..\Components\SlimDX.dll + + + + + + + + + - - ..\Components\SlimDX.dll - + + + + + + + + + + + + @@ -76,6 +121,9 @@ Always + + PreserveNewest + \ No newline at end of file diff --git a/Client/Forms/AMain.cs b/Client/Forms/AMain.cs index 50cb24bc8..c4dc9f494 100644 --- a/Client/Forms/AMain.cs +++ b/Client/Forms/AMain.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Net; using Client; using Microsoft.Web.WebView2.Core; @@ -45,7 +45,7 @@ public static void SaveError(string ex) { if (Settings.RemainingErrorLogs-- > 0) { - File.AppendAllText(@".\Error.txt", + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "Error.txt"), string.Format("[{0}] {1}{2}", DateTime.Now, ex, Environment.NewLine)); } } @@ -121,11 +121,12 @@ private void CleanUp() { if (!CleanFiles) return; - string[] fileNames = Directory.GetFiles(@".\", "*.*", SearchOption.AllDirectories); + string[] fileNames = Directory.GetFiles(AppContext.BaseDirectory, "*.*", SearchOption.AllDirectories); string fileName; + string screenshotPrefix = Path.Combine(AppContext.BaseDirectory, "Screenshots") + Path.DirectorySeparatorChar; for (int i = 0; i < fileNames.Length; i++) { - if (fileNames[i].StartsWith(".\\Screenshots\\")) continue; + if (fileNames[i].StartsWith(screenshotPrefix, StringComparison.OrdinalIgnoreCase)) continue; fileName = Path.GetFileName(fileNames[i]); @@ -303,7 +304,7 @@ public void DownloadFile(Download dl) } catch (HttpRequestException e) { - File.AppendAllText(@".\Error.txt", + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "Error.txt"), $"[{DateTime.Now}] {info.FileName} could not be downloaded. ({e.Message}) {Environment.NewLine}"); ErrorFound = true; } diff --git a/Client/Forms/CMain.cs b/Client/Forms/CMain.cs index a5a08670f..a76b0c80f 100644 --- a/Client/Forms/CMain.cs +++ b/Client/Forms/CMain.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics; using System.Drawing.Drawing2D; using System.Drawing.Imaging; @@ -13,6 +13,12 @@ using SlimDX.Direct3D9; using SlimDX.Windows; using Font = System.Drawing.Font; +using KeyEventArgs = System.Windows.Forms.KeyEventArgs; +using MouseEventArgs = System.Windows.Forms.MouseEventArgs; +using KeyPressEventArgs = System.Windows.Forms.KeyPressEventArgs; +using Keys = System.Windows.Forms.Keys; +using MouseButtons = System.Windows.Forms.MouseButtons; +using Client.Platform; namespace Client { @@ -124,7 +130,7 @@ private static void Application_Idle(object sender, EventArgs e) private static void CMain_Deactivate(object sender, EventArgs e) { - MapControl.MapButtons = MouseButtons.None; + MapControl.MapButtons = Client.Platform.MirMouseButtons.None; Shift = false; Alt = false; Ctrl = false; @@ -160,7 +166,7 @@ public static void CMain_KeyDown(object sender, KeyEventArgs e) } if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyDown(e); + MirScene.ActiveScene.OnKeyDown(e.ToNeutral()); } catch (Exception ex) @@ -178,7 +184,7 @@ public static void CMain_MouseMove(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseMove(e); + MirScene.ActiveScene.OnMouseMove(e.ToNeutral()); } catch (Exception ex) { @@ -206,7 +212,7 @@ public static void CMain_KeyUp(object sender, KeyEventArgs e) foreach (KeyBind KeyCheck in CMain.InputKeys.Keylist) { if (KeyCheck.function != KeybindOptions.Screenshot) continue; - if (KeyCheck.Key != e.KeyCode) + if (KeyCheck.Key != (Client.Platform.MirKeys)e.KeyCode) continue; if ((KeyCheck.RequireAlt != 2) && (KeyCheck.RequireAlt != (Alt ? 1 : 0))) continue; @@ -223,7 +229,7 @@ public static void CMain_KeyUp(object sender, KeyEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyUp(e); + MirScene.ActiveScene.OnKeyUp(e.ToNeutral()); } catch (Exception ex) { @@ -235,7 +241,7 @@ public static void CMain_KeyPress(object sender, KeyPressEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyPress(e); + MirScene.ActiveScene.OnKeyPress(e.ToNeutral()); } catch (Exception ex) { @@ -247,7 +253,7 @@ public static void CMain_MouseDoubleClick(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseClick(e); + MirScene.ActiveScene.OnMouseClick(e.ToNeutral()); } catch (Exception ex) { @@ -256,14 +262,14 @@ public static void CMain_MouseDoubleClick(object sender, MouseEventArgs e) } public static void CMain_MouseUp(object sender, MouseEventArgs e) { - MapControl.MapButtons &= ~e.Button; + MapControl.MapButtons &= ~e.Button.ToNeutral(); if (e.Button != MouseButtons.Right || !Settings.NewMove) GameScene.CanRun = false; try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseUp(e); + MirScene.ActiveScene.OnMouseUp(e.ToNeutral()); } catch (Exception ex) { @@ -290,7 +296,7 @@ public static void CMain_MouseDown(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseDown(e); + MirScene.ActiveScene.OnMouseDown(e.ToNeutral()); } catch (Exception ex) { @@ -302,7 +308,7 @@ public static void CMain_MouseClick(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseClick(e); + MirScene.ActiveScene.OnMouseClick(e.ToNeutral()); } catch (Exception ex) { @@ -314,7 +320,7 @@ public static void CMain_MouseWheel(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseWheel(e); + MirScene.ActiveScene.OnMouseWheel(e.ToNeutral()); } catch (Exception ex) { @@ -635,7 +641,7 @@ public static void SaveError(string ex) { if (Settings.RemainingErrorLogs-- > 0) { - File.AppendAllText(@".\Error.txt", + File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "Error.txt"), string.Format("[{0}] {1}{2}", Now, ex, Environment.NewLine)); } } diff --git a/Client/KeyBindSettings.cs b/Client/KeyBindSettings.cs index b5a11e9ba..ed9bf6f7e 100644 --- a/Client/KeyBindSettings.cs +++ b/Client/KeyBindSettings.cs @@ -1,4 +1,4 @@ -namespace Client +namespace Client { public enum KeybindOptions : int @@ -121,7 +121,7 @@ public class KeyBind public class KeyBindSettings { - private static InIReader Reader = new InIReader(@".\KeyBinds.ini"); + private static InIReader Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "KeyBinds.ini")); public List Keylist = new List(); public List DefaultKeylist = new List(); @@ -130,7 +130,7 @@ public KeyBindSettings() New(Keylist); New(DefaultKeylist); - if (!File.Exists(@".\KeyBinds.ini")) + if (!File.Exists(Path.Combine(AppContext.BaseDirectory, "KeyBinds.ini"))) { Save(DefaultKeylist); return; diff --git a/Client/MirControls/MirAmountBox.cs b/Client/MirControls/MirAmountBox.cs index 0bea07ccd..e5a05dfd7 100644 --- a/Client/MirControls/MirAmountBox.cs +++ b/Client/MirControls/MirAmountBox.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; using Client.MirSounds; namespace Client.MirControls @@ -235,12 +235,14 @@ public override void Show() Highlight(); +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = Program.Form.Controls[i] as TextBox; if (T != null && T.Tag != null && T.Tag != null) ((MirTextBox)T.Tag).DialogChanged(); } +#endif /* CMain.Shift = false; @@ -287,12 +289,14 @@ protected override void Dispose(bool disposing) if (!disposing) return; +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = (TextBox)Program.Form.Controls[i]; if (T != null && T.Tag != null && T.Tag != null) ((MirTextBox)T.Tag).DialogChanged(); } +#endif } #endregion diff --git a/Client/MirControls/MirControl.cs b/Client/MirControls/MirControl.cs index 9de49d7d0..48600b45a 100644 --- a/Client/MirControls/MirControl.cs +++ b/Client/MirControls/MirControl.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; using Client.MirSounds; using SlimDX; using SlimDX.Direct3D9; @@ -129,6 +129,7 @@ public bool DrawControlTexture Redraw(); } } +#if !FNA protected virtual void CreateTexture() { if (ControlTexture == null || ControlTexture.Disposed) @@ -159,6 +160,17 @@ internal void DisposeTexture() DXManager.ControlList.Remove(this); } +#else + protected virtual void CreateTexture() + { + TextureValid = true; + } + + internal void DisposeTexture() + { + TextureValid = false; + } +#endif #endregion #region Controls @@ -713,6 +725,7 @@ protected virtual void BeforeDrawControl() if (BeforeDraw != null) BeforeDraw.Invoke(this, EventArgs.Empty); } +#if !FNA protected internal virtual void DrawControl() { if (!DrawControlTexture) @@ -728,6 +741,28 @@ protected internal virtual void DrawControl() CleanTime = CMain.Time + Settings.CleanDelay; } +#else + protected internal virtual void DrawControl() + { + if (!DrawControlTexture) + return; + + if (BackColour.A > 0 && Size.Width > 0 && Size.Height > 0) + { + DXManager.Renderer?.DrawRectangle(new Rectangle(DisplayLocation.X, DisplayLocation.Y, Size.Width, Size.Height), BackColour, Opacity); + } + + if (!TextureValid) + CreateTexture(); + + if (ControlTexture != null && !ControlTexture.IsDisposed) + { + DXManager.DrawOpaque(ControlTexture, new Rectangle(0, 0, Size.Width, Size.Height), new Vector3?(new Vector3((float)(DisplayLocation.X), (float)(DisplayLocation.Y), 0.0f)), Color.White, Opacity); + } + + CleanTime = CMain.Time + Settings.CleanDelay; + } +#endif protected void DrawChildControls() { if (Controls != null) @@ -737,10 +772,27 @@ protected void DrawChildControls() } protected virtual void DrawBorder() { +#if !FNA if (!Border || BorderInfo == null) return; DXManager.Sprite.Flush(); DXManager.Line.Draw(BorderInfo, _borderColour); +#else + if (!Border) + return; + + if (DXManager.Renderer != null) + { + // Top border + DXManager.Renderer.DrawRectangle(new Rectangle(DisplayRectangle.Left - 1, DisplayRectangle.Top - 1, DisplayRectangle.Width + 1, 1), BorderColour, Opacity); + // Bottom border + DXManager.Renderer.DrawRectangle(new Rectangle(DisplayRectangle.Left - 1, DisplayRectangle.Bottom, DisplayRectangle.Width + 1, 1), BorderColour, Opacity); + // Left border + DXManager.Renderer.DrawRectangle(new Rectangle(DisplayRectangle.Left - 1, DisplayRectangle.Top - 1, 1, DisplayRectangle.Height + 2), BorderColour, Opacity); + // Right border + DXManager.Renderer.DrawRectangle(new Rectangle(DisplayRectangle.Right, DisplayRectangle.Top - 1, 1, DisplayRectangle.Height + 2), BorderColour, Opacity); + } +#endif } protected void AfterDrawControl() { @@ -785,7 +837,7 @@ protected virtual void Highlight() if (MouseControl != null) MouseControl.Dehighlight(); - if (ActiveControl != null && ActiveControl != this) return; + if (ActiveControl != null && ActiveControl != this && !(ActiveControl is MirTextBox)) return; OnMouseEnter(); MouseControl = this; @@ -905,6 +957,13 @@ public virtual void OnMouseDown(MouseEventArgs e) if (!_enabled) return; +#if FNA + if (!(this is MirTextBox) && MirScene.ActiveScene != null) + { + UnfocusAllTextBoxes(MirScene.ActiveScene); + } +#endif + Activate(); TrySort(); @@ -918,6 +977,24 @@ public virtual void OnMouseDown(MouseEventArgs e) if (MouseDown != null) MouseDown.Invoke(this, e); } + +#if FNA + private void UnfocusAllTextBoxes(MirControl parent) + { + if (parent == null) return; + if (parent is MirTextBox textBox) + { + textBox.LoseFocus(); + } + if (parent.Controls != null) + { + for (int i = 0; i < parent.Controls.Count; i++) + { + UnfocusAllTextBoxes(parent.Controls[i]); + } + } + } +#endif public virtual void OnMouseUp(MouseEventArgs e) { if (!_enabled) @@ -929,7 +1006,7 @@ public virtual void OnMouseUp(MouseEventArgs e) _movePoint = Point.Empty; } - if (ActiveControl != null) ActiveControl.Deactivate(); + if (ActiveControl != null && !(ActiveControl is MirTextBox)) ActiveControl.Deactivate(); if (MouseUp != null) MouseUp.Invoke(this, e); @@ -998,7 +1075,7 @@ public virtual void Redraw() #region Font public virtual System.Drawing.Font ScaleFont(System.Drawing.Font font) { - var theFont = new System.Drawing.Font(font.Name, font.Size * 96f / CMain.Graphics.DpiX, font.Style); + var theFont = new System.Drawing.Font(font.Name, font.Size * 120f / CMain.Graphics.DpiX, font.Style); font.Dispose(); return theFont; diff --git a/Client/MirControls/MirImageControl.cs b/Client/MirControls/MirImageControl.cs index 3935b617d..d802df2e6 100644 --- a/Client/MirControls/MirImageControl.cs +++ b/Client/MirControls/MirImageControl.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; namespace Client.MirControls { @@ -194,7 +194,16 @@ protected internal override void DrawControl() public override bool IsMouseOver(Point p) { - return base.IsMouseOver(p) && (!_pixelDetect || Library.VisiblePixel(Index, p.Subtract(DisplayLocation),true) || Moving); + if (!base.IsMouseOver(p)) return false; + if (!_pixelDetect || Moving) return true; + if (Controls != null) + { + for (int i = 0; i < Controls.Count; i++) + { + if (Controls[i].IsMouseOver(p)) return true; + } + } + return Library.VisiblePixel(Index, p.Subtract(DisplayLocation), true); } #region Disposable diff --git a/Client/MirControls/MirInputBox.cs b/Client/MirControls/MirInputBox.cs index 9f1d82d1e..ea0eb1945 100644 --- a/Client/MirControls/MirInputBox.cs +++ b/Client/MirControls/MirInputBox.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; namespace Client.MirControls { @@ -118,12 +118,14 @@ public override void Show() Highlight(); +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = Program.Form.Controls[i] as TextBox; if (T != null && T.Tag != null && T.Tag != null) ((MirTextBox)T.Tag).DialogChanged(); } +#endif } @@ -135,12 +137,14 @@ protected override void Dispose(bool disposing) if (!disposing) return; +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = (TextBox)Program.Form.Controls[i]; if (T != null && T.Tag != null && T.Tag != null) ((MirTextBox)T.Tag).DialogChanged(); } +#endif } #endregion diff --git a/Client/MirControls/MirLabel.cs b/Client/MirControls/MirLabel.cs index 2674934e0..1f29366d2 100644 --- a/Client/MirControls/MirLabel.cs +++ b/Client/MirControls/MirLabel.cs @@ -1,9 +1,13 @@ -using System.Drawing.Drawing2D; +#if !FNA +using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.Drawing.Text; -using Client.MirGraphics; using SlimDX; using SlimDX.Direct3D9; +#else +using FontStashSharp; +#endif +using Client.MirGraphics; using Font = System.Drawing.Font; namespace Client.MirControls @@ -146,6 +150,9 @@ private void GetSize() #region Label private string _text; +#if FNA + private string _wrappedText; +#endif public string Text { get { return _text; } @@ -195,6 +202,7 @@ protected override unsafe void CreateTexture() if (TextureSize != Size) DisposeTexture(); +#if !FNA if (ControlTexture == null || ControlTexture.Disposed) { DXManager.ControlList.Add(this); @@ -237,8 +245,113 @@ protected override unsafe void CreateTexture() ControlTexture.UnlockRectangle(0); DXManager.Sprite.Flush(); TextureValid = true; +#else + var font = Client.Platform.FNA.FNAFontManager.GetFont(Font.Size); + float singleLineHeight = font.MeasureString("A").Y; + + if (!AutoSize && Size.Width > 0 && Size.Height > 0 && !string.IsNullOrEmpty(Text)) + { + if ((DrawFormat & TextFormatFlags.WordBreak) == TextFormatFlags.WordBreak && Size.Height >= singleLineHeight * 1.5f) + { + _wrappedText = WrapText(font, Text, Size.Width); + } + else + { + string[] lines = Text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + for (int i = 0; i < lines.Length; i++) + { + lines[i] = TruncateText(font, lines[i], Size.Width); + } + _wrappedText = string.Join("\n", lines); + } + + if (singleLineHeight > 0) + { + string[] wrappedLines = _wrappedText.Split('\n'); + int maxLines = (int)(Size.Height / singleLineHeight); + if (maxLines < wrappedLines.Length) + { + if (maxLines < 1) maxLines = 1; + if (maxLines < wrappedLines.Length) + { + string[] truncatedLines = new string[maxLines]; + Array.Copy(wrappedLines, truncatedLines, maxLines); + _wrappedText = string.Join("\n", truncatedLines); + } + } + } + } + else + { + _wrappedText = Text; + } + TextureValid = true; +#endif } +#if FNA + protected internal override void DrawControl() + { + if (string.IsNullOrEmpty(Text)) + return; + + var renderer = DXManager.Renderer as Client.Platform.FNA.FNARenderer; + if (renderer == null) + return; + + if (!TextureValid) + CreateTexture(); + + if (BackColour.A > 0 && Size.Width > 0 && Size.Height > 0) + { + renderer.DrawRectangle(new Rectangle(DisplayLocation.X, DisplayLocation.Y, Size.Width, Size.Height), BackColour, Opacity); + } + + var font = Client.Platform.FNA.FNAFontManager.GetFont(Font.Size); + var xnaForeCol = new Microsoft.Xna.Framework.Color(ForeColour.R, ForeColour.G, ForeColour.B, ForeColour.A) * Opacity; + + var pos = new Microsoft.Xna.Framework.Vector2(DisplayLocation.X, DisplayLocation.Y); + + string drawText = _wrappedText ?? Text; + var measuredSize = font.MeasureString(drawText); + + if ((DrawFormat & TextFormatFlags.HorizontalCenter) == TextFormatFlags.HorizontalCenter) + { + pos.X += (Size.Width - measuredSize.X) / 2f; + } + else if ((DrawFormat & TextFormatFlags.Right) == TextFormatFlags.Right) + { + pos.X += Size.Width - measuredSize.X; + } + + if ((DrawFormat & TextFormatFlags.VerticalCenter) == TextFormatFlags.VerticalCenter) + { + pos.Y += (Size.Height - measuredSize.Y) / 2f; + } + else if ((DrawFormat & TextFormatFlags.Bottom) == TextFormatFlags.Bottom) + { + pos.Y += Size.Height - measuredSize.Y; + } + + if (OutLine) + { + var xnaOutCol = new Microsoft.Xna.Framework.Color(OutLineColour.R, OutLineColour.G, OutLineColour.B, OutLineColour.A) * Opacity; + + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaOutCol); + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(0, 1), xnaOutCol); + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(2, 1), xnaOutCol); + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 2), xnaOutCol); + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 1), xnaForeCol); + } + else + { + renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaForeCol); + } + + CleanTime = CMain.Time + Settings.CleanDelay; + } +#endif + #region Disposable protected override void Dispose(bool disposing) { @@ -270,5 +383,130 @@ protected override void Dispose(bool disposing) } #endregion +#if FNA + private string TruncateText(FontStashSharp.SpriteFontBase font, string text, float maxWidth) + { + if (maxWidth <= 0 || string.IsNullOrEmpty(text)) + return text; + + if (font.MeasureString(text).X <= maxWidth) + return text; + + int low = 1; + int high = text.Length; + int bestLength = 0; + + while (low <= high) + { + int mid = (low + high) / 2; + string sub = text.Substring(0, mid); + if (font.MeasureString(sub).X <= maxWidth) + { + bestLength = mid; + low = mid + 1; + } + else + { + high = mid - 1; + } + } + + return text.Substring(0, bestLength); + } + + private string WrapText(FontStashSharp.SpriteFontBase font, string text, float maxWidth) + { + if (maxWidth <= 0) + return text; + + string[] lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + List wrappedLines = new List(); + + foreach (string line in lines) + { + if (font.MeasureString(line).X <= maxWidth) + { + wrappedLines.Add(line); + continue; + } + + System.Text.StringBuilder currentLine = new System.Text.StringBuilder(); + string lastWord = ""; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + bool isCJK = (c >= 0x4E00 && c <= 0x9FFF) || + (c >= 0x3400 && c <= 0x4DBF) || + (c >= 0x3000 && c <= 0x303F) || + (c >= 0x3040 && c <= 0x309F) || + (c >= 0x30A0 && c <= 0x30FF) || + (c >= 0xFF00 && c <= 0xFFEF); + + if (isCJK || char.IsWhiteSpace(c)) + { + if (lastWord.Length > 0) + { + string test = currentLine.ToString() + lastWord; + if (font.MeasureString(test).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + } + currentLine.Append(lastWord); + lastWord = ""; + } + + string testChar = currentLine.ToString() + c; + if (font.MeasureString(testChar).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + if (!char.IsWhiteSpace(c)) + { + currentLine.Append(c); + } + } + else + { + currentLine.Append(c); + } + } + else + { + lastWord += c; + } + } + + if (lastWord.Length > 0) + { + string test = currentLine.ToString() + lastWord; + if (font.MeasureString(test).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + } + currentLine.Append(lastWord); + } + + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + } + } + + return string.Join("\n", wrappedLines); + } +#endif + } } diff --git a/Client/MirControls/MirMessageBox.cs b/Client/MirControls/MirMessageBox.cs index 21d7252dc..ab97a9b0b 100644 --- a/Client/MirControls/MirMessageBox.cs +++ b/Client/MirControls/MirMessageBox.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; namespace Client.MirControls { @@ -150,12 +150,14 @@ public override void Show() Highlight(); +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = Program.Form.Controls[i] as TextBox; if (T != null && T.Tag != null && T.Tag != null) ((MirTextBox)T.Tag).DialogChanged(); } +#endif } @@ -222,7 +224,11 @@ public static void Show(string message, bool close = false) { MirMessageBox box = new MirMessageBox(message); +#if !FNA if (close) box.OKButton.Click += (o, e) => Program.Form.Close(); +#else + if (close) box.OKButton.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif box.Show(); } @@ -243,12 +249,14 @@ protected override void Dispose(bool disposing) YesButton = null; Buttons = 0; +#if !FNA for (int i = 0; i < Program.Form.Controls.Count; i++) { TextBox T = (TextBox) Program.Form.Controls[i]; if (T != null && T.Tag != null) ((MirTextBox) T.Tag).DialogChanged(); } +#endif } #endregion diff --git a/Client/MirControls/MirScene.cs b/Client/MirControls/MirScene.cs index 5284b3f6e..0f962a2f1 100644 --- a/Client/MirControls/MirScene.cs +++ b/Client/MirControls/MirScene.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; using Client.MirNetwork; using Client.MirScenes; using SlimDX.Direct3D9; @@ -34,7 +34,15 @@ public override void Draw() OnBeforeShown(); +#if !FNA DrawControl(); +#else + BeforeDrawControl(); + DrawControl(); + DrawChildControls(); + DrawBorder(); + AfterDrawControl(); +#endif if (CMain.DebugBaseLabel != null && !CMain.DebugBaseLabel.IsDisposed) CMain.DebugBaseLabel.Draw(); @@ -47,6 +55,7 @@ public override void Draw() protected override void CreateTexture() { +#if !FNA if (Size != TextureSize) DisposeTexture(); @@ -72,6 +81,9 @@ protected override void CreateTexture() DXManager.SetSurface(oldSurface); TextureValid = true; surface.Dispose(); +#else + TextureValid = true; +#endif } public override void OnMouseDown(MouseEventArgs e) @@ -120,7 +132,11 @@ public override void OnMouseClick(MouseEventArgs e) return; if (_buttons == e.Button) { +#if !FNA if (_lastClickTime + SystemInformation.DoubleClickTime >= CMain.Time) +#else + if (_lastClickTime + 500 >= CMain.Time) +#endif { OnMouseDoubleClick(e); return; diff --git a/Client/MirControls/MirScrollingLabel.cs b/Client/MirControls/MirScrollingLabel.cs index 1d3ccdf47..7cb8eae32 100644 --- a/Client/MirControls/MirScrollingLabel.cs +++ b/Client/MirControls/MirScrollingLabel.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; namespace Client.MirControls { @@ -73,14 +73,19 @@ public void NewText(List lines, bool resetIndex = true) Capture capture = match.Groups[1].Captures[0]; string[] values = capture.Value.Split('/'); currentLine = currentLine.Remove(capture.Index - 1 - offSet, capture.Length + 2).Insert(capture.Index - 1 - offSet, values[0]); - string text = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; +#if FNA + string text = currentLine.Substring(0, capture.Index - 1 - offSet); Size size = TextRenderer.MeasureText(CMain.Graphics, text, _textLabel[i - Index].Font, _textLabel[i - Index].Size, TextFormatFlags.TextBoxControl); - //if (R.Match(match.Value).Success) - // NewButton(values[0], values[1], TextLabel[i].Location.Add(new Point(size.Width - 10, 0))); + if (C.Match(match.Value).Success) + NewColour(values[0], values[1], _textLabel[i - Index].Location.Add(new Point(size.Width, 0))); +#else + string text = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; + Size size = TextRenderer.MeasureText(CMain.Graphics, text, _textLabel[i - Index].Font, _textLabel[i - Index].Size, TextFormatFlags.TextBoxControl); if (C.Match(match.Value).Success) NewColour(values[0], values[1], _textLabel[i - Index].Location.Add(new Point(size.Width - 10, 0))); +#endif } _textLabel[i - Index].Text = currentLine; diff --git a/Client/MirControls/MirTextBox.cs b/Client/MirControls/MirTextBox.cs index db1f1a256..41e2f8212 100644 --- a/Client/MirControls/MirTextBox.cs +++ b/Client/MirControls/MirTextBox.cs @@ -1,7 +1,398 @@ -using Client.MirGraphics; +#if !WINDOWS +using System; +using System.Drawing; +using System.Linq; +using Client.MirGraphics; +using Client.MirScenes; + +namespace Client.MirControls +{ + public sealed class MirTextBox : MirControl + { + public bool CanLoseFocus { get; set; } = true; + public int MaxLength { get; set; } = 100; + public bool Password { get; set; } + + private string _text = ""; + public string Text + { + get => _text; + set + { + _text = value ?? ""; + UpdateLabel(); + TextChangedEvent?.Invoke(this, EventArgs.Empty); + } + } + + public string[] MultiText + { + get => new string[] { _text }; + set + { + if (value != null && value.Length > 0) + Text = value[0]; + } + } + + private readonly MirLabel _textLabel; + private readonly MirLabel _caretLabel; + private bool _isFocused; + public bool Focused => _isFocused; + + public class TextBoxStub + { + private readonly MirTextBox _parent; + public TextBoxStub(MirTextBox parent) => _parent = parent; + + public string Text + { + get => _parent.Text; + set => _parent.Text = value; + } + + public event KeyPressEventHandler KeyPress + { + add => _parent.KeyPressEvent += value; + remove => _parent.KeyPressEvent -= value; + } + + public event EventHandler TextChanged + { + add => _parent.TextChangedEvent += value; + remove => _parent.TextChangedEvent -= value; + } + + public event KeyEventHandler KeyDown + { + add => _parent.KeyDownEvent += value; + remove => _parent.KeyDownEvent -= value; + } + + public event KeyEventHandler KeyUp + { + add => _parent.KeyUpEvent += value; + remove => _parent.KeyUpEvent -= value; + } + + public int SelectionStart { get; set; } + public int SelectionLength { get; set; } + public int GetFirstCharIndexFromLine(int line) => 0; + public void ScrollToCaret() { } + + public int MaxLength + { + get => _parent.MaxLength; + set => _parent.MaxLength = value; + } + + public bool Focused => _parent.Focused; + + public event EventHandler GotFocus; + public void OnGotFocus() => GotFocus?.Invoke(this, EventArgs.Empty); + } + + private event KeyPressEventHandler KeyPressEvent; + private event EventHandler TextChangedEvent; + private event KeyEventHandler KeyDownEvent; + private event KeyEventHandler KeyUpEvent; + public TextBoxStub TextBox { get; } + + public MirTextBox() + { + TextBox = new TextBoxStub(this); + BackColour = Color.Black; + DrawControlTexture = true; + TextureValid = false; + + _textLabel = new MirLabel + { + AutoSize = true, + BackColour = Color.Transparent, + ForeColour = Color.White, + Parent = this, + Location = new Point(2, 2), + NotControl = true + }; + + _caretLabel = new MirLabel + { + AutoSize = true, + BackColour = Color.Transparent, + ForeColour = Color.White, + Text = "|", + Parent = this, + Location = new Point(2, 2), + NotControl = true, + Visible = false, + OutLine = false + }; + + Font = new Font(Settings.FontName, 10F); + + MouseDown += OnMouseDownEvent; + } + + private void OnMouseDownEvent(object sender, MouseEventArgs e) + { + SetFocus(); + } + + public void SetFocus() + { + if (MirScene.ActiveScene != null) + { + UnfocusAllTextBoxes(MirScene.ActiveScene); + } + + Activate(); + + _isFocused = true; + _caretLabel.Visible = true; + + // Set active control on program form boundary if possible + TextureValid = false; + Redraw(); + TextBox.OnGotFocus(); +#if FNA + Microsoft.Xna.Framework.Input.TextInputEXT.StartTextInput(); +#endif + } + + private void UnfocusAllTextBoxes(MirControl parent) + { + if (parent == null) return; + if (parent is MirTextBox textBox && textBox != this) + { + textBox.LoseFocus(); + } + if (parent.Controls != null) + { + for (int i = 0; i < parent.Controls.Count; i++) + { + UnfocusAllTextBoxes(parent.Controls[i]); + } + } + } + + public void LoseFocus() + { + Deactivate(); + _isFocused = false; + _caretLabel.Visible = false; + TextureValid = false; + Redraw(); +#if FNA + Microsoft.Xna.Framework.Input.TextInputEXT.StopTextInput(); +#endif + } + + public override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + KeyDownEvent?.Invoke(this, e); + if (e.Handled) return; + + if (e.KeyCode == Client.Platform.MirKeys.Tab) + { + TryTabFocus(); + e.Handled = true; + } + } + + public override void OnKeyUp(KeyEventArgs e) + { + base.OnKeyUp(e); + KeyUpEvent?.Invoke(this, e); + } + + public override void OnKeyPress(KeyPressEventArgs e) + { + if (!_isFocused) return; + + base.OnKeyPress(e); + KeyPressEvent?.Invoke(this, e); + if (e.Handled) return; + + if (e.KeyChar == (char)Keys.Escape) + { + LoseFocus(); + e.Handled = true; + return; + } + + if (e.KeyChar == (char)Keys.Back) + { + if (Text.Length > 0) + { + Text = Text.Substring(0, Text.Length - 1); + } + e.Handled = true; + return; + } + + if (e.KeyChar == (char)Keys.Enter) + { + if (CanLoseFocus) LoseFocus(); + e.Handled = true; + return; + } + + // Append standard printable characters + if (!char.IsControl(e.KeyChar) && Text.Length < MaxLength) + { + Text += e.KeyChar; + e.Handled = true; + } + } + + private void UpdateLabel() + { + if (_textLabel == null || _caretLabel == null) return; + + if (Password) + { + _textLabel.Text = new string('*', _text.Length); + } + else + { + _textLabel.Text = _text; + } + + int labelHeight = _textLabel.Size.Height > 0 ? _textLabel.Size.Height : _caretLabel.Size.Height; + int yOffset = (Size.Height - labelHeight) / 2; + if (yOffset < 0) yOffset = 0; + + _textLabel.Location = new Point(2, yOffset); + int caretX = _textLabel.Location.X; + if (_text.Length > 0) + { + caretX += _textLabel.Size.Width - 5; + } + _caretLabel.Location = new Point(caretX, yOffset); + + TextureValid = false; + Redraw(); + } + + protected override void OnSizeChanged() + { + base.OnSizeChanged(); + UpdateLabel(); + } + + private Font _font; + public Font Font + { + get => _font ??= new Font(Settings.FontName, 10F); + set + { + _font = value; + if (value != null) + { + if (_textLabel != null) _textLabel.Font = new Font(value.Name, value.Size, value.Style); + if (_caretLabel != null) _caretLabel.Font = new Font(value.Name, value.Size, value.Style); + } + UpdateLabel(); + } + } + + protected override void OnForeColourChanged() + { + base.OnForeColourChanged(); + if (_textLabel != null) _textLabel.ForeColour = ForeColour; + if (_caretLabel != null) _caretLabel.ForeColour = ForeColour; + } + + protected override void OnVisibleChanged() + { + base.OnVisibleChanged(); + if (!Visible) + { + LoseFocus(); + } + } + + public override void Draw() + { + // Caret Blinking + if (_isFocused) + { + _caretLabel.Visible = (CMain.Time / 500) % 2 == 0; + } + base.Draw(); + } + + public override void MultiLine() + { + } + + public void DialogChanged() + { + MirMessageBox box1 = null; + MirInputBox box2 = null; + MirAmountBox box3 = null; + + if (MirScene.ActiveScene != null && MirScene.ActiveScene.Controls.Count > 0) + { + box1 = (MirMessageBox)MirScene.ActiveScene.Controls.FirstOrDefault(ob => ob is MirMessageBox); + box2 = (MirInputBox)MirScene.ActiveScene.Controls.FirstOrDefault(O => O is MirInputBox); + box3 = (MirAmountBox)MirScene.ActiveScene.Controls.FirstOrDefault(ob => ob is MirAmountBox); + } + + if ((box1 != null && box1 != Parent) || (box2 != null && box2 != Parent) || (box3 != null && box3 != Parent)) + Visible = false; + } + + private void TryTabFocus() + { + if (MirScene.ActiveScene == null) return; + var textBoxes = new System.Collections.Generic.List(); + FindTextBoxes(MirScene.ActiveScene, textBoxes); + if (textBoxes.Count <= 1) return; + + int index = textBoxes.IndexOf(this); + if (index == -1) return; + + int nextIndex; + if (CMain.Shift) + { + nextIndex = index - 1; + if (nextIndex < 0) nextIndex = textBoxes.Count - 1; + } + else + { + nextIndex = (index + 1) % textBoxes.Count; + } + textBoxes[nextIndex].SetFocus(); + } + + private static void FindTextBoxes(MirControl parent, System.Collections.Generic.List list) + { + if (parent == null || !parent.Visible) return; + if (parent is MirTextBox textBox && textBox.Enabled) + { + list.Add(textBox); + } + if (parent.Controls != null) + { + for (int i = 0; i < parent.Controls.Count; i++) + { + FindTextBoxes(parent.Controls[i], list); + } + } + } + } +} +#else +using Client.MirGraphics; using SlimDX; using SlimDX.Direct3D9; +using System; +using System.Drawing; using System.Drawing.Imaging; +using System.Linq; +using System.Windows.Forms; namespace Client.MirControls { @@ -253,7 +644,7 @@ public MirTextBox() { BackColor = BackColour, BorderStyle = BorderStyle.None, - Font = new System.Drawing.Font(Settings.FontName, 10F * 96f / CMain.Graphics.DpiX), + Font = new System.Drawing.Font(Settings.FontName, 10F * 120f / CMain.Graphics.DpiX), ForeColor = ForeColour, Location = DisplayLocation, Size = Size, @@ -421,3 +812,4 @@ protected override void Dispose(bool disposing) #endregion } } +#endif diff --git a/Client/MirGraphics/DXManager.cs b/Client/MirGraphics/DXManager.cs index 440c401db..4d8a6fb9b 100644 --- a/Client/MirGraphics/DXManager.cs +++ b/Client/MirGraphics/DXManager.cs @@ -1,4 +1,5 @@ -using System.Drawing.Drawing2D; +#if !FNA +using System.Drawing.Drawing2D; using System.Drawing.Imaging; using Client.MirControls; using Client.MirScenes; @@ -588,3 +589,212 @@ public static void Dispose() } } } +#else +using System; +using System.Drawing; +using System.Collections.Generic; +using Client.MirControls; +using Client.Platform; + +namespace Client.MirGraphics +{ + public enum BlendMode + { + Normal, + Additive, + Light, + Inverse + } + + public static class DXManager + { + public static List TextureList = new List(); + public static List ControlList = new List(); + + public static IGraphicsRenderer Renderer; + public static IAssetResolver AssetResolver; + + public static bool GrayScale; + public static float Opacity = 1F; + public static bool Blending; + public static float BlendingRate; + public static BlendMode BlendingMode; + + // Dummy textures and surfaces for target rendering + public static Texture FloorTexture; + public static Texture LightTexture; + public static object FloorSurface; + public static object LightSurface; + public static object CurrentSurface; + + public static Texture RadarTexture; + public static List Lights = new List(); + public static Texture PoisonDotBackground; + + public static Point[] LightSizes = + { + new Point(125,95), + new Point(205,156), + new Point(285,217), + new Point(365,277), + new Point(445,338), + new Point(525,399), + new Point(605,460), + new Point(685,521), + new Point(765,581), + new Point(845,642), + new Point(925,703) + }; + + public static class Sprite + { + public static object Transform { get; set; } + public static void Flush() + { + // Managed by SpriteBatch internally in FNARenderer + } + public static void Begin(SlimDX.Direct3D9.SpriteFlags flags) + { + } + public static void End() + { + } + } + + public static class Device + { + public static void Clear(int flags, Color color, float depth, int stencil) + { + Renderer?.Clear(color); + } + public static void SetRenderState(SlimDX.Direct3D9.RenderState state, SlimDX.Direct3D9.Blend value) + { + } + } + + public static void Create() + { + } + + public static void AttemptReset() + { + } + + public static void AttemptRecovery() + { + } + + public static void CleanUp() + { + for (int i = TextureList.Count - 1; i >= 0; i--) + { + var texture = TextureList[i]; + if (texture == null) continue; + texture.TextureValid = false; + texture.Image?.Dispose(); + texture.MaskImage?.Dispose(); + } + TextureList.Clear(); + + for (int i = ControlList.Count - 1; i >= 0; i--) + { + var c = ControlList[i]; + if (c == null) continue; + c.DisposeTexture(); + } + ControlList.Clear(); + } + + public static void Clean() + { + for (int i = TextureList.Count - 1; i >= 0; i--) + { + MImage m = TextureList[i]; + + if (m == null) + { + TextureList.RemoveAt(i); + continue; + } + + if (CMain.Time <= m.CleanTime) continue; + + m.DisposeTexture(); + } + + for (int i = ControlList.Count - 1; i >= 0; i--) + { + MirControl c = ControlList[i]; + + if (c == null) + { + ControlList.RemoveAt(i); + continue; + } + + if (CMain.Time <= c.CleanTime) continue; + + c.DisposeTexture(); + } + } + + + public static void Dispose() + { + CleanUp(); + FloorTexture?.Dispose(); + LightTexture?.Dispose(); + } + + public static void SetSurface(object surface) + { + CurrentSurface = surface; + Renderer?.SetSurface(surface); + } + + public static void SetGrayscale(bool value) + { + GrayScale = value; + Renderer?.SetGrayscale(value); + } + + public static void SetOpacity(float opacity) + { + Opacity = opacity; + Renderer?.SetOpacity(opacity); + } + + public static void SetBlend(bool blend, float rate = 1f, BlendMode mode = BlendMode.Normal) + { + Blending = blend; + BlendingRate = rate; + BlendingMode = mode; + Renderer?.SetBlend(blend, rate, mode); + } + + public static void Draw(Texture texture, Rectangle? sourceRect, Vector3? position, Color color) + { + if (Renderer == null || texture == null) return; + var rect = sourceRect ?? new Rectangle(0, 0, texture.Width, texture.Height); + var pos = position != null ? new Point((int)position.Value.X, (int)position.Value.Y) : Point.Empty; + + if (Blending) + Renderer.DrawBlend(texture, rect, pos, color, BlendingRate); + else + Renderer.Draw(texture, rect, pos, color); + } + + public static void DrawOpaque(Texture texture, Rectangle? sourceRect, Vector3? position, Color color, float opacity) + { + if (Renderer == null || texture == null) return; + var rect = sourceRect ?? new Rectangle(0, 0, texture.Width, texture.Height); + var pos = position != null ? new Point((int)position.Value.X, (int)position.Value.Y) : Point.Empty; + + if (Blending) + Renderer.DrawBlend(texture, rect, pos, color, BlendingRate); // DrawBlend handles opacity/rate inside renderer + else + Renderer.DrawOpaque(texture, rect, pos, color, opacity); + } + } +} +#endif diff --git a/Client/MirGraphics/MLibrary.cs b/Client/MirGraphics/MLibrary.cs index 737129320..2a03c9217 100644 --- a/Client/MirGraphics/MLibrary.cs +++ b/Client/MirGraphics/MLibrary.cs @@ -1,5 +1,7 @@ -using SlimDX; +#if !FNA +using SlimDX; using SlimDX.Direct3D9; +#endif using System.IO.Compression; using Frame = Client.MirObjects.Frame; using Client.MirObjects; @@ -486,9 +488,6 @@ public sealed class MLibrary private int _count; private bool _initialized; - private BinaryReader _reader; - private FileStream _fStream; - public FrameSet Frames { get { return _frames; } @@ -503,46 +502,54 @@ public void Initialize() { _initialized = true; - if (!File.Exists(_fileName)) + var exists = DXManager.AssetResolver?.Exists(_fileName) ?? File.Exists(_fileName); + if (!exists) return; try { - _fStream = new FileStream(_fileName, FileMode.Open, FileAccess.Read); - _reader = new BinaryReader(_fStream); - int currentVersion = _reader.ReadInt32(); - if (currentVersion < 2) + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) { - System.Windows.Forms.MessageBox.Show("Wrong version, expecting lib version: " + LibVersion.ToString() + " found version: " + currentVersion.ToString() + ".", _fileName, System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error, System.Windows.Forms.MessageBoxDefaultButton.Button1); - System.Windows.Forms.Application.Exit(); - return; - } - _count = _reader.ReadInt32(); + int currentVersion = reader.ReadInt32(); + if (currentVersion < 2) + { +#if !FNA + System.Windows.Forms.MessageBox.Show("Wrong version, expecting lib version: " + LibVersion.ToString() + " found version: " + currentVersion.ToString() + ".", _fileName, System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error, System.Windows.Forms.MessageBoxDefaultButton.Button1); + System.Windows.Forms.Application.Exit(); +#else + CMain.SaveError("Wrong version, expecting lib version: " + LibVersion.ToString() + " found version: " + currentVersion.ToString() + " for file: " + _fileName); + Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif + return; + } + _count = reader.ReadInt32(); - int frameSeek = 0; - if (currentVersion >= 3) - { - frameSeek = _reader.ReadInt32(); - } + int frameSeek = 0; + if (currentVersion >= 3) + { + frameSeek = reader.ReadInt32(); + } - _images = new MImage[_count]; - _indexList = new int[_count]; + _images = new MImage[_count]; + _indexList = new int[_count]; - for (int i = 0; i < _count; i++) - _indexList[i] = _reader.ReadInt32(); + for (int i = 0; i < _count; i++) + _indexList[i] = reader.ReadInt32(); - if (currentVersion >= 3) - { - _fStream.Seek(frameSeek, SeekOrigin.Begin); + if (currentVersion >= 3) + { + fStream.Seek(frameSeek, SeekOrigin.Begin); - var frameCount = _reader.ReadInt32(); + var frameCount = reader.ReadInt32(); - if (frameCount > 0) - { - _frames = new FrameSet(); - for (int i = 0; i < frameCount; i++) + if (frameCount > 0) { - _frames.Add((MirAction)_reader.ReadByte(), new Frame(_reader)); + _frames = new FrameSet(); + for (int i = 0; i < frameCount; i++) + { + _frames.Add((MirAction)reader.ReadByte(), new Frame(reader)); + } } } } @@ -564,16 +571,24 @@ private bool CheckImage(int index) if (_images[index] == null) { - _fStream.Position = _indexList[index]; - _images[index] = new MImage(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Position = _indexList[index]; + _images[index] = new MImage(reader); + } } MImage mi = _images[index]; if (!mi.TextureValid) { if ((mi.Width == 0) || (mi.Height == 0)) return false; - _fStream.Seek(_indexList[index] + 17, SeekOrigin.Begin); - mi.CreateTexture(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Seek(_indexList[index] + 17, SeekOrigin.Begin); + mi.CreateTexture(reader); + } } return true; @@ -588,8 +603,12 @@ public Point GetOffSet(int index) if (_images[index] == null) { - _fStream.Seek(_indexList[index], SeekOrigin.Begin); - _images[index] = new MImage(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Seek(_indexList[index], SeekOrigin.Begin); + _images[index] = new MImage(reader); + } } return new Point(_images[index].X, _images[index].Y); @@ -602,8 +621,12 @@ public Size GetSize(int index) if (_images[index] == null) { - _fStream.Seek(_indexList[index], SeekOrigin.Begin); - _images[index] = new MImage(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Seek(_indexList[index], SeekOrigin.Begin); + _images[index] = new MImage(reader); + } } return new Size(_images[index].Width, _images[index].Height); @@ -618,8 +641,12 @@ public Size GetTrueSize(int index) if (_images[index] == null) { - _fStream.Position = _indexList[index]; - _images[index] = new MImage(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Position = _indexList[index]; + _images[index] = new MImage(reader); + } } MImage mi = _images[index]; if (mi.TrueSize.IsEmpty) @@ -629,8 +656,12 @@ public Size GetTrueSize(int index) if ((mi.Width == 0) || (mi.Height == 0)) return Size.Empty; - _fStream.Seek(_indexList[index] + 17, SeekOrigin.Begin); - mi.CreateTexture(_reader); + using (var fStream = DXManager.AssetResolver != null ? DXManager.AssetResolver.OpenRead(_fileName) : new FileStream(_fileName, FileMode.Open, FileAccess.Read)) + using (var reader = new BinaryReader(fStream)) + { + fStream.Seek(_indexList[index] + 17, SeekOrigin.Begin); + mi.CreateTexture(reader); + } } return mi.GetTrueSize(); } @@ -684,6 +715,8 @@ public void Draw(int index, Point point, Color colour, bool offSet, float opacit if (point.X >= Settings.ScreenWidth || point.Y >= Settings.ScreenHeight || point.X + mi.Width < 0 || point.Y + mi.Height < 0) return; + + DXManager.DrawOpaque(mi.Image, new Rectangle(0, 0, mi.Width, mi.Height), new Vector3((float)point.X, (float)point.Y, 0.0F), colour, opacity); mi.CleanTime = CMain.Time + Settings.CleanDelay; @@ -766,11 +799,19 @@ public void Draw(int index, Point point, Size size, Color colour) float scaleX = (float)size.Width / mi.Width; float scaleY = (float)size.Height / mi.Height; +#if !FNA Matrix matrix = Matrix.Scaling(scaleX, scaleY, 0); DXManager.Sprite.Transform = matrix; DXManager.Draw(mi.Image, new Rectangle(0, 0, mi.Width, mi.Height), new Vector3((float)point.X / scaleX, (float)point.Y / scaleY, 0.0F), Color.White); DXManager.Sprite.Transform = Matrix.Identity; +#else + var matrix = Microsoft.Xna.Framework.Matrix.CreateScale(scaleX, scaleY, 1.0f); + DXManager.Renderer?.SetTransform(matrix); + DXManager.Draw(mi.Image, new Rectangle(0, 0, mi.Width, mi.Height), new Microsoft.Xna.Framework.Vector3((float)point.X / scaleX, (float)point.Y / scaleY, 0.0F), Color.White); + + DXManager.Renderer?.ResetTransform(); +#endif mi.CleanTime = CMain.Time + Settings.CleanDelay; } @@ -877,6 +918,20 @@ public sealed class MImage public unsafe byte* Data; + ~MImage() + { +#if FNA + unsafe + { + if (Data != null) + { + System.Runtime.InteropServices.Marshal.FreeHGlobal((IntPtr)Data); + Data = null; + } + } +#endif + } + public MImage(BinaryReader reader) { //read layer 1 @@ -907,6 +962,7 @@ public unsafe void CreateTexture(BinaryReader reader) int w = Width;// + (4 - Width % 4) % 4; int h = Height;// + (4 - Height % 4) % 4; +#if !FNA Image = new Texture(DXManager.Device, w, h, 1, Usage.None, Format.A8R8G8B8, Pool.Managed); DataRectangle stream = Image.LockRectangle(0, LockFlags.Discard); Data = (byte*)stream.Data.DataPointer; @@ -930,6 +986,70 @@ public unsafe void CreateTexture(BinaryReader reader) stream.Data.Dispose(); MaskImage.UnlockRectangle(0); } +#else + Image = new Microsoft.Xna.Framework.Graphics.Texture2D(Client.Platform.FNA.FNAEntry.Instance.GraphicsDevice, w, h, false, Microsoft.Xna.Framework.Graphics.SurfaceFormat.Color); + using (var memoryStream = new System.IO.MemoryStream()) + { + DecompressImage(reader.ReadBytes(Length), memoryStream); + var rawBytes = memoryStream.ToArray(); + int pixelCount = w * h; + int[] pixels = new int[pixelCount]; + int byteLen = rawBytes.Length; + + // Safely convert BGRA/ARGB from decompression to XNA RGBA + for (int i = 0; i < pixelCount; i++) + { + int byteIdx = i * 4; + if (byteIdx + 3 < byteLen) + { + byte b = rawBytes[byteIdx]; + byte g = rawBytes[byteIdx + 1]; + byte r = rawBytes[byteIdx + 2]; + byte a = rawBytes[byteIdx + 3]; + pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); + } + } + Image.SetData(pixels); + + // Allocate and copy data for VisiblePixel checks under FNA + int allocLen = w * h * 4; + Data = (byte*)System.Runtime.InteropServices.Marshal.AllocHGlobal(allocLen); + byte[] zeroBytes = new byte[allocLen]; + System.Runtime.InteropServices.Marshal.Copy(zeroBytes, 0, (IntPtr)Data, allocLen); + System.Runtime.InteropServices.Marshal.Copy(rawBytes, 0, (IntPtr)Data, Math.Min(rawBytes.Length, allocLen)); + } + + if (HasMask) + { + reader.ReadBytes(12); + w = Width; + h = Height; + + MaskImage = new Microsoft.Xna.Framework.Graphics.Texture2D(Client.Platform.FNA.FNAEntry.Instance.GraphicsDevice, w, h, false, Microsoft.Xna.Framework.Graphics.SurfaceFormat.Color); + using (var memoryStream = new System.IO.MemoryStream()) + { + DecompressImage(reader.ReadBytes(Length), memoryStream); + var rawBytes = memoryStream.ToArray(); + int pixelCount = w * h; + int[] pixels = new int[pixelCount]; + int byteLen = rawBytes.Length; + + for (int i = 0; i < pixelCount; i++) + { + int byteIdx = i * 4; + if (byteIdx + 3 < byteLen) + { + byte b = rawBytes[byteIdx]; + byte g = rawBytes[byteIdx + 1]; + byte r = rawBytes[byteIdx + 2]; + byte a = rawBytes[byteIdx + 3]; + pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); + } + } + MaskImage.SetData(pixels); + } + } +#endif DXManager.TextureList.Add(this); TextureValid = true; @@ -941,6 +1061,7 @@ public unsafe void DisposeTexture() { DXManager.TextureList.Remove(this); +#if !FNA if (Image != null && !Image.Disposed) { Image.Dispose(); @@ -950,6 +1071,23 @@ public unsafe void DisposeTexture() { MaskImage.Dispose(); } +#else + if (Image != null && !Image.IsDisposed) + { + Image.Dispose(); + } + + if (MaskImage != null && !MaskImage.IsDisposed) + { + MaskImage.Dispose(); + } + + if (Data != null) + { + System.Runtime.InteropServices.Marshal.FreeHGlobal((IntPtr)Data); + Data = null; + } +#endif TextureValid = false; Image = null; diff --git a/Client/MirGraphics/ParticleEngine.cs b/Client/MirGraphics/ParticleEngine.cs index 4e00952d1..cd9474289 100644 --- a/Client/MirGraphics/ParticleEngine.cs +++ b/Client/MirGraphics/ParticleEngine.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics.Particles; +using Client.MirGraphics.Particles; using Client.MirScenes; using SlimDX; using System; @@ -139,7 +139,7 @@ public virtual Particle GenerateNewParticle(ParticleType type) BlendRate = 0.1F, AliveTime = DateTime.MaxValue, Blend = true, - BlendMode = BlendMode.NORMAL + BlendMode = Client.MirGraphics.BlendMode.Normal //BlendRate = (rate / (float)100), }; @@ -155,7 +155,7 @@ public virtual Particle GenerateNewParticle(ParticleType type) BlendRate = 0.1F, AliveTime = DateTime.MaxValue, Blend = true, - BlendMode = BlendMode.NORMAL + BlendMode = Client.MirGraphics.BlendMode.Normal //BlendRate = (rate / (float)100), }; particles.Add(particle); diff --git a/Client/MirGraphics/Particles/Particle.cs b/Client/MirGraphics/Particles/Particle.cs index 0790bf527..be5e116f8 100644 --- a/Client/MirGraphics/Particles/Particle.cs +++ b/Client/MirGraphics/Particles/Particle.cs @@ -1,4 +1,4 @@ -using Client.MirObjects; +using Client.MirObjects; using Client.MirScenes; using SlimDX; using System; @@ -45,7 +45,11 @@ public class Particle { public ParticleImageInfo ImageInfo { get; set; } public ParticleEngine Engine { get; set; } +#if !FNA public BlendMode BlendMode = BlendMode.NORMAL; +#else + public BlendMode BlendMode = BlendMode.Normal; +#endif public Vector2 OldPosition = Vector2.Zero; public Vector2 Position { diff --git a/Client/MirNetwork/Network.cs b/Client/MirNetwork/Network.cs index 873589eed..bd322fdd8 100644 --- a/Client/MirNetwork/Network.cs +++ b/Client/MirNetwork/Network.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net.Sockets; using Client.MirControls; using C = ClientPackets; @@ -36,7 +36,11 @@ public static void Connect() ErrorShown = true; MirMessageBox errorBox = new(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.ErrorConnectingToServer), MirMessageBoxButtons.Cancel); +#if !FNA errorBox.CancelButton.Click += (o, e) => Program.Form.Close(); +#else + errorBox.CancelButton.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif errorBox.Label.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MaximumConnectionAttemptsReached), MaxAttempts); errorBox.Show(); return; @@ -141,7 +145,7 @@ private static void ReceiveData(IAsyncResult result) _receiveList.Enqueue(p); } - CMain.BytesReceived += data.Count; + CMain.BytesReceived += (uint)data.Count; BeginReceive(); } @@ -241,7 +245,7 @@ public static void Process() data.AddRange(p.GetPacketBytes()); } - CMain.BytesSent += data.Count; + CMain.BytesSent += (uint)data.Count; BeginSend(data); } diff --git a/Client/MirObjects/MapCode.cs b/Client/MirObjects/MapCode.cs index 8d25c34eb..9664330d4 100644 --- a/Client/MirObjects/MapCode.cs +++ b/Client/MirObjects/MapCode.cs @@ -1,4 +1,4 @@ -namespace Client.MirObjects +namespace Client.MirObjects { public class CellInfo { @@ -145,9 +145,10 @@ public MapReader(string FileName) private void initiate() { - if (File.Exists(FileName)) + var exists = Client.MirGraphics.DXManager.AssetResolver?.Exists(FileName) ?? File.Exists(FileName); + if (exists) { - Bytes = File.ReadAllBytes(FileName); + Bytes = Client.MirGraphics.DXManager.AssetResolver != null ? Client.MirGraphics.DXManager.AssetResolver.ReadAllBytes(FileName) : File.ReadAllBytes(FileName); } else { diff --git a/Client/MirScenes/Dialogs/GuildDialog.cs b/Client/MirScenes/Dialogs/GuildDialog.cs index 2e0b0b6a0..1d1f68340 100644 --- a/Client/MirScenes/Dialogs/GuildDialog.cs +++ b/Client/MirScenes/Dialogs/GuildDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirObjects; @@ -84,7 +84,7 @@ public GuildRankOptions GetMyOptions() #region StatusPagePub public MirLabel StatusLevelLabel; - public MirLabel StatusHeaders; + public MirLabel StatusGuildNameHeader, StatusLevelHeader, StatusMembersHeader; public MirLabel StatusGuildName, StatusLevel, StatusMembers; public MirImageControl StatusExpBar; public MirLabel StatusExpLabel, RecruitMemberLabel; @@ -516,13 +516,41 @@ public GuildDialog() StatusMembers.Text = string.Format("{0}{1}", MemberCount, MaxMembers == 0 ? "" : ("/" + MaxMembers.ToString())); } }; - StatusHeaders = new MirLabel() + string[] headers = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.GuildNameLevelMembers) + .Split(new[] { "\r\n", "\n" }, System.StringSplitOptions.RemoveEmptyEntries); + string guildNameHeader = headers.Length > 0 ? headers[0] : ""; + string levelHeader = headers.Length > 1 ? headers[1] : ""; + string membersHeader = headers.Length > 2 ? headers[2] : ""; + + StatusGuildNameHeader = new MirLabel() { Location = new Point(7, 47), DrawFormat = TextFormatFlags.Right, - Size = new Size(75, 300), + Size = new Size(75, 20), + NotControl = true, + Text = guildNameHeader, + Visible = true, + Parent = StatusPage, + ForeColour = Color.Gray, + }; + StatusLevelHeader = new MirLabel() + { + Location = new Point(7, 73), + DrawFormat = TextFormatFlags.Right, + Size = new Size(75, 20), + NotControl = true, + Text = levelHeader, + Visible = true, + Parent = StatusPage, + ForeColour = Color.Gray, + }; + StatusMembersHeader = new MirLabel() + { + Location = new Point(7, 99), + DrawFormat = TextFormatFlags.Right, + Size = new Size(75, 20), NotControl = true, - Text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.GuildNameLevelMembers), + Text = membersHeader, Visible = true, Parent = StatusPage, ForeColour = Color.Gray, diff --git a/Client/MirScenes/Dialogs/InventoryDialog.cs b/Client/MirScenes/Dialogs/InventoryDialog.cs index 61510e9de..6f190887a 100644 --- a/Client/MirScenes/Dialogs/InventoryDialog.cs +++ b/Client/MirScenes/Dialogs/InventoryDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirObjects; @@ -211,10 +211,17 @@ public InventoryDialog() void Button_Click(object sender, EventArgs e) { // Ctrl + Left-click: move selected item to the tab's bag without switching tabs. +#if !FNA if (GameScene.SelectedCell != null && e is MouseEventArgs me && me.Button == MouseButtons.Left && (Control.ModifierKeys & Keys.Control) == Keys.Control) +#else + if (GameScene.SelectedCell != null && + e is MouseEventArgs me && + me.Button == MouseButtons.Left && + CMain.Ctrl) +#endif { if (sender == ItemButton) { diff --git a/Client/MirScenes/Dialogs/MainDialogs.cs b/Client/MirScenes/Dialogs/MainDialogs.cs index 9b6b14bda..fada23ba7 100644 --- a/Client/MirScenes/Dialogs/MainDialogs.cs +++ b/Client/MirScenes/Dialogs/MainDialogs.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; @@ -606,6 +606,15 @@ public ChatDialog() ChatTextBox.TextBox.KeyDown += ChatTextBox_KeyDown; ChatTextBox.TextBox.KeyUp += ChatTextBox_KeyUp; + MouseDown += (o, e) => + { + if (!ChatTextBox.Visible && ChatTextBox.DisplayRectangle.Contains(CMain.MPoint)) + { + ChatTextBox.Visible = true; + ChatTextBox.SetFocus(); + } + }; + HomeButton = new MirButton { Index = 2018, @@ -998,10 +1007,17 @@ public void Update() capture = match.Groups[1].Captures[0]; string[] values = capture.Value.Split('/'); currentLine = currentLine.Remove(capture.Index - 1 - offSet, capture.Length + 2).Insert(capture.Index - 1 - offSet, values[0]); +#if FNA + string text = currentLine.Substring(0, capture.Index - 1 - offSet); + Size size = TextRenderer.MeasureText(CMain.Graphics, text, temp.Font, temp.Size, TextFormatFlags.TextBoxControl); + + ChatLink(values[0], ulong.Parse(values[1]), temp.Location.Add(new Point(size.Width, 0))); +#else string text = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; Size size = TextRenderer.MeasureText(CMain.Graphics, text, temp.Font, temp.Size, TextFormatFlags.TextBoxControl); ChatLink(values[0], ulong.Parse(values[1]), temp.Location.Add(new Point(size.Width - 10, 0))); +#endif } catch(Exception ex) { diff --git a/Client/MirScenes/Dialogs/NPCDialogs.cs b/Client/MirScenes/Dialogs/NPCDialogs.cs index 8bef5efe5..636c7beb2 100644 --- a/Client/MirScenes/Dialogs/NPCDialogs.cs +++ b/Client/MirScenes/Dialogs/NPCDialogs.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Globalization; using System.Text.RegularExpressions; using Client.MirControls; @@ -485,6 +485,19 @@ public void NewText(List lines, bool resetIndex = true) string action = match.Groups[3].Captures[0].Value; currentLine = currentLine.Remove(capture.Index - 1 - offSet, capture.Length + 2).Insert(capture.Index - 1 - offSet, txt); +#if FNA + string text2 = currentLine.Substring(0, capture.Index - 1 - offSet); + Size size2 = TextRenderer.MeasureText(CMain.Graphics, text2, TextLabel[i].Font, TextLabel[i].Size, TextFormatFlags.TextBoxControl); + + if (R.Match(match.Value).Success) + NewButton(txt, action, TextLabel[i].Location.Add(new Point(size2.Width, 0))); + + if (C.Match(match.Value).Success) + NewColour(txt, action, TextLabel[i].Location.Add(new Point(size2.Width, 0))); + + if (L.Match(match.Value).Success) + NewButton(txt, null, TextLabel[i].Location.Add(new Point(size2.Width, 0)), action); +#else string text2 = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; Size size2 = TextRenderer.MeasureText(CMain.Graphics, text2, TextLabel[i].Font, TextLabel[i].Size, TextFormatFlags.TextBoxControl); @@ -496,6 +509,7 @@ public void NewText(List lines, bool resetIndex = true) if (L.Match(match.Value).Success) NewButton(txt, null, TextLabel[i].Location.Add(new Point(size2.Width - 10, 0)), action); +#endif } } TextLabel[i].Text = currentLine; diff --git a/Client/MirScenes/Dialogs/NoticeDialog.cs b/Client/MirScenes/Dialogs/NoticeDialog.cs index 2fb386f47..25859c4ce 100644 --- a/Client/MirScenes/Dialogs/NoticeDialog.cs +++ b/Client/MirScenes/Dialogs/NoticeDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirSounds; using System.Text.RegularExpressions; @@ -294,6 +294,20 @@ public void NewText(List lines, bool resetIndex = true) string action = match.Groups[3].Captures[0].Value; currentLine = currentLine.Remove(capture.Index - 1 - offSet, capture.Length + 2).Insert(capture.Index - 1 - offSet, txt); +#if FNA + string text = currentLine.Substring(0, capture.Index - 1 - offSet); + Size size = TextRenderer.MeasureText(CMain.Graphics, text, TextLabel[i].Font, TextLabel[i].Size, TextFormatFlags.TextBoxControl); + + if (L.Match(match.Value).Success) + { + NewLink(txt, action, TextLabel[i].Location.Add(new Point(size.Width, 0))); + } + + if (C.Match(match.Value).Success) + { + NewColour(txt, action, TextLabel[i].Location.Add(new Point(size.Width, 0))); + } +#else string text = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; Size size = TextRenderer.MeasureText(CMain.Graphics, text, TextLabel[i].Font, TextLabel[i].Size, TextFormatFlags.TextBoxControl); @@ -306,6 +320,7 @@ public void NewText(List lines, bool resetIndex = true) { NewColour(txt, action, TextLabel[i].Location.Add(new Point(size.Width - 11, 0))); } +#endif } TextLabel[i].Text = currentLine; diff --git a/Client/MirScenes/Dialogs/QuestDialogs.cs b/Client/MirScenes/Dialogs/QuestDialogs.cs index dca2666df..95660e812 100644 --- a/Client/MirScenes/Dialogs/QuestDialogs.cs +++ b/Client/MirScenes/Dialogs/QuestDialogs.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.Text.RegularExpressions; using System.Threading.Tasks; using Client.MirControls; @@ -1321,11 +1321,19 @@ private void NewText(List lines, bool resetIndex = true) Capture capture = match.Groups[1].Captures[0]; string[] values = capture.Value.Split('/'); currentLine = currentLine.Remove(capture.Index - 1 - offSet, capture.Length + 2).Insert(capture.Index - 1 - offSet, values[0]); +#if FNA + string text = currentLine.Substring(0, capture.Index - 1 - offSet); + Size size = TextRenderer.MeasureText(CMain.Graphics, text, _textLabel[i - TopLine].Font, _textLabel[i - TopLine].Size, TextFormatFlags.TextBoxControl); + + if (C.Match(match.Value).Success) + NewColour(values[0], values[1], _textLabel[i - TopLine].Location.Add(new Point(size.Width, 0))); +#else string text = currentLine.Substring(0, capture.Index - 1 - offSet) + " "; Size size = TextRenderer.MeasureText(CMain.Graphics, text, _textLabel[i - TopLine].Font, _textLabel[i - TopLine].Size, TextFormatFlags.TextBoxControl); if (C.Match(match.Value).Success) NewColour(values[0], values[1], _textLabel[i - TopLine].Location.Add(new Point(size.Width - 10, 0))); +#endif } _textLabel[i - TopLine].Text = currentLine; diff --git a/Client/MirScenes/Dialogs/TrustMerchantDialog.cs b/Client/MirScenes/Dialogs/TrustMerchantDialog.cs index 13974e865..48cf9794d 100644 --- a/Client/MirScenes/Dialogs/TrustMerchantDialog.cs +++ b/Client/MirScenes/Dialogs/TrustMerchantDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirSounds; @@ -1052,7 +1052,9 @@ private void SearchTextBox_KeyPress(object sender, KeyPressEventArgs e) Match = SearchTextBox.Text, MarketType = MarketType }); +#if !FNA Program.Form.ActiveControl = null; +#endif break; case (char)Keys.Escape: e.Handled = true; diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 1bd08c75e..808686fc5 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirObjects; @@ -503,6 +503,8 @@ private void ProcessOuput() } private void GameScene_KeyDown(object sender, KeyEventArgs e) { + if (MirControl.ActiveControl is MirTextBox) return; + if (GameScene.Scene.KeyboardLayoutDialog.WaitingForBind != null) { GameScene.Scene.KeyboardLayoutDialog.CheckNewInput(e); @@ -1128,7 +1130,11 @@ public void QuitGame() { //If Last Combat < 10 CANCEL MirMessageBox messageBox = new MirMessageBox(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.ExitTip), MirMessageBoxButtons.YesNo); +#if !FNA messageBox.YesButton.Click += (o, e) => Program.Form.Close(); +#else + messageBox.YesButton.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif messageBox.Show(); } else @@ -5774,8 +5780,8 @@ private void GuildNameRequest(S.GuildNameRequest p) MirInputBox inputBox = new MirInputBox(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.EnterGuildNameLengthLimit)); inputBox.InputTextBox.TextBox.KeyPress += (o, e) => { - string Allowed = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - if (!Allowed.Contains(e.KeyChar) && e.KeyChar != (char)Keys.Back) + char c = e.KeyChar; + if (!char.IsLetterOrDigit(c) && c != (char)Keys.Back) e.Handled = true; }; inputBox.OKButton.Click += (o, e) => @@ -10406,6 +10412,7 @@ public MapControl() BackColour = Color.Black; MouseDown += OnMouseDown; + MouseUp += OnMouseUp; MouseMove += (o, e) => MouseLocation = e.Location; Click += OnMouseClick; } @@ -10543,11 +10550,401 @@ public static MapObject GetObject(uint targetID) public override void Draw() { - //Do nothing. +#if FNA + if (User == null) return; + + DrawBackground(); + + // Draw Floor directly + int startX = User.Movement.X - ViewRangeX; + int endX = User.Movement.X + ViewRangeX; + int startY = User.Movement.Y - ViewRangeY; + int endY = User.Movement.Y + ViewRangeY; + int endYExtended = endY + 5; + + int[] drawXCache = new int[endX - startX + 1]; + for (int xi = startX; xi <= endX; xi++) + drawXCache[xi - startX] = (xi - User.Movement.X + OffSetX) * CellWidth - OffSetX + User.OffSetMove.X; + + int[] drawYCache = new int[endYExtended - startY + 1]; + for (int yi = startY; yi <= endYExtended; yi++) + drawYCache[yi - startY] = (yi - User.Movement.Y + OffSetY) * CellHeight + User.OffSetMove.Y; + + for (int y = startY; y <= endYExtended; y++) + { + if (y <= 0) continue; + if (y >= Height) break; + + int drawY = drawYCache[y - startY]; + + for (int x = startX; x <= endX; x++) + { + if (x < 0) continue; + if (x >= Width) break; + + int drawX = drawXCache[x - startX]; + var cell = M2CellInfo[x, y]; + + // Back + if (y % 2 == 0 && x % 2 == 0 && y <= endY) + { + if (cell.BackImage != 0 && cell.BackIndex != -1) + { + int index = (cell.BackImage & 0x1FFFFFFF) - 1; + var lib = Libraries.MapLibs[cell.BackIndex]; + lib.Draw(index, drawX, drawY); + } + } + + // Middle + int midIndex = cell.MiddleImage - 1; + if (midIndex >= 0 && cell.MiddleIndex != -1) + { + var lib = Libraries.MapLibs[cell.MiddleIndex]; + Size s = lib.GetSize(midIndex); + if ((s.Width == CellWidth && s.Height == CellHeight) || + (s.Width == CellWidth * 2 && s.Height == CellHeight * 2)) + { + lib.Draw(midIndex, drawX, drawY); + } + } + + // Front + int frontIndex = (cell.FrontImage & 0x7FFF) - 1; + if (frontIndex != -1) + { + int fileIndex = cell.FrontIndex; + if (fileIndex != -1 && fileIndex != 200) + { + var lib = Libraries.MapLibs[fileIndex]; + Size s = lib.GetSize(frontIndex); + + // door + if (cell.DoorIndex > 0) + { + Door doorInfo = GetDoor(cell.DoorIndex); + if (doorInfo == null) + { + doorInfo = new Door() { index = cell.DoorIndex, DoorState = 0, ImageIndex = 0, LastTick = CMain.Time }; + Doors.Add(doorInfo); + } + else if (doorInfo.DoorState != 0) + { + frontIndex += (doorInfo.ImageIndex + 1) * cell.DoorOffset; + } + } + + if (frontIndex >= 0 && + ((s.Width == CellWidth && s.Height == CellHeight) || + (s.Width == CellWidth * 2 && s.Height == CellHeight * 2))) + { + lib.Draw(frontIndex, drawX, drawY); + } + } + } + } + } + + DrawObjects(); + + // render weather + foreach (ParticleEngine engine in GameScene.Scene.ParticleEngines) + { + engine.Draw(); + } + + LightSetting setting = Lights == LightSetting.Normal ? GameScene.Scene.Lights : Lights; + + if (setting != LightSetting.Day || GameScene.User.Poison.HasFlag(PoisonType.Blindness)) + { + Color darkness; + + switch (setting) + { + case LightSetting.Night: + { + switch (MapDarkLight) + { + case 1: + darkness = Color.FromArgb(255, 20, 20, 20); + break; + case 2: + darkness = Color.LightSlateGray; + break; + case 3: + darkness = Color.SkyBlue; + break; + case 4: + darkness = Color.Goldenrod; + break; + default: + darkness = Color.Black; + break; + } + } + break; + case LightSetting.Evening: + case LightSetting.Dawn: + darkness = Color.FromArgb(255, 50, 50, 50); + break; + default: + case LightSetting.Day: + darkness = Color.FromArgb(255, 255, 255, 255); + break; + } + + if (MapObject.User.Poison.HasFlag(PoisonType.Blindness)) + { + darkness = GetBlindLight(darkness); + } + + var lightsList = new List(); + + #region Object Lights (Player/Mob/NPC) + foreach (var ob in Objects.Values) + { + if (ob.Light > 0 && (!ob.Dead || ob == MapObject.User || ob.Race == ObjectType.Spell)) + { + int lightRange = ob.Light % 15; + if (lightRange >= DXManager.LightSizes.Length) + lightRange = DXManager.LightSizes.Length - 1; + + var lightSize = DXManager.LightSizes[lightRange]; + var drawLocation = ob.DrawLocation; + + Color lightColour = ob.LightColour; + + if (ob.Race == ObjectType.Player) + { + switch (ob.Light / 15) + { + case 0://no light source + lightColour = Color.FromArgb(255, 60, 60, 60); + break; + case 1: + lightColour = Color.FromArgb(255, 120, 120, 120); + break; + case 2://Candle + lightColour = Color.FromArgb(255, 180, 180, 180); + break; + case 3://Torch + lightColour = Color.FromArgb(255, 240, 240, 240); + break; + default://Peddler Torch + lightColour = Color.FromArgb(255, 255, 255, 255); + break; + } + } + else if (ob.Race == ObjectType.Merchant) + { + lightColour = Color.FromArgb(255, 120, 120, 120); + } + + if (MapObject.User.Poison.HasFlag(PoisonType.Blindness)) + { + lightColour = GetBlindLight(lightColour); + } + + int centerX = drawLocation.X - (CellWidth / 2); + int centerY = drawLocation.Y - (CellHeight / 2) - 5; + + lightsList.Add(new Client.Platform.MirLightSource + { + Center = new Point(centerX, centerY), + Width = lightSize.X, + Height = lightSize.Y, + Color = lightColour + }); + } + + #region Object Effect Lights + if (Settings.Effect) + { + for (int e = 0; e < ob.Effects.Count; e++) + { + Effect effect = ob.Effects[e]; + if (!effect.Blend || CMain.Time < effect.Start || (!(effect is Missile) && effect.Light < ob.Light)) continue; + + int light = effect.Light; + if (light >= DXManager.LightSizes.Length) + light = DXManager.LightSizes.Length - 1; + + var lightSize = DXManager.LightSizes[light]; + var drawLocation = effect.DrawLocation; + + var lightColour = effect.LightColour; + + if (MapObject.User.Poison.HasFlag(PoisonType.Blindness)) + { + lightColour = GetBlindLight(lightColour); + } + + int centerX = drawLocation.X - (CellWidth / 2); + int centerY = drawLocation.Y - (CellHeight / 2) - 5; + + lightsList.Add(new Client.Platform.MirLightSource + { + Center = new Point(centerX, centerY), + Width = lightSize.X, + Height = lightSize.Y, + Color = lightColour + }); + } + } + #endregion + } + #endregion + + #region Map Effect Lights + if (Settings.Effect) + { + for (int e = 0; e < Effects.Count; e++) + { + Effect effect = Effects[e]; + if (!effect.Blend || CMain.Time < effect.Start) continue; + + int light = effect.Light; + if (light == 0) continue; + if (light >= DXManager.LightSizes.Length) + light = DXManager.LightSizes.Length - 1; + + var lightSize = DXManager.LightSizes[light]; + var drawLocation = effect.DrawLocation; + + var lightColour = Color.White; + + if (MapObject.User.Poison.HasFlag(PoisonType.Blindness)) + { + lightColour = GetBlindLight(lightColour); + } + + int centerX = drawLocation.X - (CellWidth / 2); + int centerY = drawLocation.Y - (CellHeight / 2) - 5; + + lightsList.Add(new Client.Platform.MirLightSource + { + Center = new Point(centerX, centerY), + Width = lightSize.X, + Height = lightSize.Y, + Color = lightColour + }); + } + } + #endregion + + #region Map Lights + for (int y = MapObject.User.Movement.Y - ViewRangeY - 24; y <= MapObject.User.Movement.Y + ViewRangeY + 24; y++) + { + if (y < 0) continue; + if (y >= Height) break; + for (int x = MapObject.User.Movement.X - ViewRangeX - 24; x < MapObject.User.Movement.X + ViewRangeX + 24; x++) + { + if (x < 0) continue; + if (x >= Width) break; + int imageIndex = (M2CellInfo[x, y].FrontImage & 0x7FFF) - 1; + if (imageIndex == -1) continue; + int fileIndex = M2CellInfo[x, y].FrontIndex; + if (fileIndex == -1) continue; + if (M2CellInfo[x, y].Light <= 0 || M2CellInfo[x, y].Light >= 10) continue; + if (M2CellInfo[x, y].Light == 0) continue; + + Color lightIntensity; + + int light = (M2CellInfo[x, y].Light % 10) * 3; + + switch (M2CellInfo[x, y].Light / 10) + { + case 1: + lightIntensity = Color.FromArgb(255, 255, 255, 255); + break; + case 2: + lightIntensity = Color.FromArgb(255, 120, 180, 255); + break; + case 3: + lightIntensity = Color.FromArgb(255, 255, 180, 120); + break; + case 4: + lightIntensity = Color.FromArgb(255, 22, 160, 5); + break; + default: + lightIntensity = Color.FromArgb(255, 255, 255, 255); + break; + } + + if (MapObject.User.Poison.HasFlag(PoisonType.Blindness)) + { + lightIntensity = GetBlindLight(lightIntensity); + } + + var p = new Point( + (x + OffSetX - MapObject.User.Movement.X) * CellWidth + MapObject.User.OffSetMove.X, + (y + OffSetY - MapObject.User.Movement.Y) * CellHeight + MapObject.User.OffSetMove.Y + 32); + + if (M2CellInfo[x, y].FrontAnimationFrame > 0) + p.Offset(Libraries.MapLibs[fileIndex].GetOffSet(imageIndex)); + + if (light >= DXManager.LightSizes.Length) + light = DXManager.LightSizes.Length - 1; + + var lightSize = DXManager.LightSizes[light]; + + int centerX = p.X - (CellWidth / 2) + 10; + int centerY = p.Y - (CellHeight / 2) - 5; + + lightsList.Add(new Client.Platform.MirLightSource + { + Center = new Point(centerX, centerY), + Width = lightSize.X, + Height = lightSize.Y, + Color = lightIntensity + }); + } + } + #endregion + + DXManager.Renderer?.RenderGPULights(lightsList, darkness); + } + + if (Settings.DropView || GameScene.DropViewTime > CMain.Time) + { + foreach (var ob in Objects.Values.OfType()) + { + if (!ob.MouseOver(MouseLocation)) + ob.DrawName(); + } + } + + if (MapObject.MouseObject != null && !(MapObject.MouseObject is ItemObject)) + MapObject.MouseObject.DrawName(); + + int offSet = 0; + + if (Settings.DisplayBodyName) + { + foreach (var ob in Objects.Values.OfType()) + { + if (ob.MouseOver(MouseLocation)) + ob.DrawName(); + } + } + + foreach (var ob in Objects.Values.OfType()) + { + if (ob.MouseOver(MouseLocation)) + { + ob.DrawName(offSet); + offSet -= ob.NameLabel.Size.Height + (ob.NameLabel.Border ? 1 : 0); + } + } + + if (MapObject.User.MouseOver(MouseLocation)) + MapObject.User.DrawName(); +#endif } protected override void CreateTexture() { +#if !FNA if (User == null) return; if (!FloorValid) @@ -10631,10 +11028,13 @@ protected override void CreateTexture() DXManager.SetSurface(oldSurface); surface.Dispose(); TextureValid = true; - +#else + TextureValid = true; +#endif } protected internal override void DrawControl() { +#if !FNA if (!DrawControlTexture) return; @@ -10653,10 +11053,12 @@ protected internal override void DrawControl() if (MapObject.User.Dead) DXManager.SetGrayscale(false); CleanTime = CMain.Time + Settings.CleanDelay; +#endif } private void DrawFloor() { +#if !FNA if (DXManager.FloorTexture == null || DXManager.FloorTexture.Disposed) { DXManager.FloorTexture = new Texture(DXManager.Device, Settings.ScreenWidth, Settings.ScreenHeight, 1, Usage.RenderTarget, Format.A8R8G8B8, Pool.Default); @@ -10761,6 +11163,7 @@ private void DrawFloor() DXManager.SetSurface(oldSurface); FloorValid = true; +#endif } private void DrawBackground() { @@ -10945,7 +11348,9 @@ private void DrawObjects() } } +#if !FNA DXManager.Sprite.Flush(); +#endif float oldOpacity = DXManager.Opacity; DXManager.SetOpacity(0.4F); @@ -11023,6 +11428,7 @@ private Color GetBlindLight(Color light) private void DrawLights(LightSetting setting) { +#if !FNA if (DXManager.Lights == null || DXManager.Lights.Count == 0) return; if (DXManager.LightTexture == null || DXManager.LightTexture.Disposed) @@ -11271,6 +11677,7 @@ private void DrawLights(LightSetting setting) DXManager.Sprite.End(); DXManager.Sprite.Begin(SpriteFlags.AlphaBlend); +#endif } private static void OnMouseClick(object sender, EventArgs e) @@ -11356,6 +11763,13 @@ private static void OnMouseClick(object sender, EventArgs e) } } + private static void OnMouseUp(object sender, MouseEventArgs e) + { + MapButtons &= ~e.Button; + if (e.Button != MouseButtons.Right || !Settings.NewMove) + GameScene.CanRun = false; + } + private static void OnMouseDown(object sender, MouseEventArgs e) { MapButtons |= e.Button; diff --git a/Client/MirScenes/LoginScene.cs b/Client/MirScenes/LoginScene.cs index 368a6caac..d10b31955 100644 --- a/Client/MirScenes/LoginScene.cs +++ b/Client/MirScenes/LoginScene.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text.RegularExpressions; using Client.MirControls; using Client.MirGraphics; @@ -69,7 +69,11 @@ public LoginScene() BorderColour = Color.Black, Location = new Point(5, Settings.ScreenHeight - 20), Parent = _background, +#if !FNA Text = string.Format("Build: {0}.{1}.{2}", Globals.ProductCodename, Settings.UseTestConfig ? "Debug" : "Release", Application.ProductVersion), +#else + Text = string.Format("Build: {0}.{1}.{2}", Globals.ProductCodename, Settings.UseTestConfig ? "Debug" : "Release", System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "FNA"), +#endif }; TestLabel = new MirImageControl @@ -82,7 +86,11 @@ public LoginScene() }; _connectBox = new MirMessageBox(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AttemptingConnectServer), MirMessageBoxButtons.Cancel); +#if !FNA _connectBox.CancelButton.Click += (o, e) => Program.Form.Close(); +#else + _connectBox.CancelButton.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif Shown += (sender, args) => { Network.Connect(); @@ -139,7 +147,11 @@ private void SendVersion() { byte[] sum; using (MD5 md5 = MD5.Create()) +#if !FNA using (FileStream stream = File.OpenRead(Application.ExecutablePath)) +#else + using (FileStream stream = File.OpenRead(System.Reflection.Assembly.GetEntryAssembly()?.Location ?? System.IO.Path.Combine(AppContext.BaseDirectory, "Client.dll"))) +#endif sum = md5.ComputeHash(stream); p.VersionHash = sum; @@ -411,7 +423,11 @@ public LoginDialog() Parent = this, PressedIndex = 331, }; +#if !FNA CloseButton.Click += (o, e) => Program.Form.Close(); +#else + CloseButton.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif PasswordTextBox = new MirTextBox { diff --git a/Client/MirScenes/SelectScene.cs b/Client/MirScenes/SelectScene.cs index 252010f07..4dc51aeef 100644 --- a/Client/MirScenes/SelectScene.cs +++ b/Client/MirScenes/SelectScene.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirScenes.Dialogs; @@ -115,7 +115,11 @@ public SelectScene(List characters) Parent = Background, PressedIndex = 354 }; +#if !FNA ExitGame.Click += (o, e) => Program.Form.Close(); +#else + ExitGame.Click += (o, e) => Client.Platform.FNA.FNAEntry.Instance.Exit(); +#endif CharacterDisplay = new MirAnimatedControl diff --git a/Client/MirSounds/SoundManager.cs b/Client/MirSounds/SoundManager.cs index 0790fddfc..8514534bd 100644 --- a/Client/MirSounds/SoundManager.cs +++ b/Client/MirSounds/SoundManager.cs @@ -1,6 +1,259 @@ -using Client.MirSounds.Libraries; +#if FNA +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Audio; +using Client.MirGraphics; + +namespace Client.MirSounds +{ + public static class SoundManager + { + private static Dictionary _indexList => SoundList.Indexes; + private static List> _delayList = new List>(); + + private static Dictionary _cachedOneShots = new Dictionary(); + private static Dictionary _loopingSounds = new Dictionary(); + private static SoundEffectInstance _music; + public static IDisposable Music => null; + + private static int _vol; + private static int _musicVol; + + public static readonly List SupportedFileTypes; + private static long _checkSoundTime; + + public static int Vol + { + get => _vol; + set + { + if (_vol == value) return; + _vol = value; + AdjustAllVolumes(); + } + } + + public static int MusicVol + { + get => _musicVol; + set + { + if (_musicVol == value) return; + _musicVol = value; + if (_music != null) + { + _music.Volume = ScaleVolume(_musicVol); + } + } + } + + static SoundManager() + { + _checkSoundTime = CMain.Time + 30 * 1000; + + SupportedFileTypes = new List + { + ".ogg", + ".wav" + }; + + SoundList.LoadSoundList(); + } + + public static void Create() + { + // FNA automatically configures and binds FAudio/OpenAL, no manual device initialization needed! + } + + public static void PlaySound(int index, bool loop = false, int delay = 0) + { + if (delay > 0) + { + _delayList.Add(new KeyValuePair(CMain.Time + delay, index)); + return; + } + + if (!_indexList.ContainsKey(index)) + { + string filename = index > 20000 ? + string.Format("M{0:0}-{1:0}", (index - 20000) / 10, index % 10) : + string.Format("{0:000}-{1:0}", index / 10, index % 10); + + _indexList.Add(index, filename); + } + + string fileBaseName = _indexList[index]; + + if (!loop) + { + if (!_cachedOneShots.TryGetValue(index, out SoundEffect sound)) + { + sound = LoadSoundEffect(fileBaseName); + if (sound != null) + { + _cachedOneShots.Add(index, sound); + } + } + + if (sound != null) + { + sound.Play(ScaleVolume(_vol), 0.0f, 0.0f); + } + } + else + { + if (_loopingSounds.TryGetValue(index, out SoundEffectInstance instance)) + { + instance.Stop(); + instance.Dispose(); + _loopingSounds.Remove(index); + } + + var sound = LoadSoundEffect(fileBaseName); + if (sound != null) + { + var inst = sound.CreateInstance(); + inst.IsLooped = true; + inst.Volume = ScaleVolume(_vol); + inst.Play(); + _loopingSounds.Add(index, inst); + } + } + } + + private static SoundEffect LoadSoundEffect(string fileName) + { + string ext = Path.GetExtension(fileName).ToLower(); + string resolvedPath = null; + + if (ext == ".ogg" || ext == ".wav") + { + string rawPath = Path.Combine(Settings.SoundPath, fileName); + resolvedPath = DXManager.AssetResolver.ResolveSound(rawPath); + } + else + { + // Try appending extensions + string rawPath = Path.Combine(Settings.SoundPath, fileName + ".ogg"); + resolvedPath = DXManager.AssetResolver.ResolveSound(rawPath); + + if (!File.Exists(resolvedPath)) + { + rawPath = Path.Combine(Settings.SoundPath, fileName + ".wav"); + resolvedPath = DXManager.AssetResolver.ResolveSound(rawPath); + } + } + + if (File.Exists(resolvedPath)) + { + try + { + using (var stream = File.OpenRead(resolvedPath)) + { + return SoundEffect.FromStream(stream); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading sound {fileName}: {ex}"); + } + } + return null; + } + + public static void StopSound(int index) + { + if (_loopingSounds.TryGetValue(index, out SoundEffectInstance instance)) + { + instance.Stop(); + instance.Dispose(); + _loopingSounds.Remove(index); + } + } + + public static void PlayMusic(int index, bool loop = false) + { + StopMusic(); + + if (_indexList.TryGetValue(index, out string fileName)) + { + var sound = LoadSoundEffect(fileName); + if (sound != null) + { + _music = sound.CreateInstance(); + _music.IsLooped = loop; + _music.Volume = ScaleVolume(_musicVol); + _music.Play(); + } + } + } + + public static void StopMusic() + { + if (_music != null) + { + _music.Stop(); + _music.Dispose(); + _music = null; + } + } + + public static void ProcessDelayedSounds() + { + if (_delayList.Count == 0) return; + + var sounds = _delayList.Where(x => x.Key <= CMain.Time).ToList(); + + foreach (var sound in sounds) + { + _delayList.Remove(sound); + PlaySound(sound.Value); + } + } + + private static void AdjustAllVolumes() + { + float vol = ScaleVolume(_vol); + foreach (var instance in _loopingSounds.Values) + { + instance.Volume = vol; + } + } + + private static float ScaleVolume(int volume) + { + return Math.Clamp(volume / 100f, 0.0f, 1.0f); + } + + public static void Dispose() + { + StopMusic(); + + foreach (var sound in _loopingSounds.Values) + { + sound.Stop(); + sound.Dispose(); + } + _loopingSounds.Clear(); + + foreach (var sound in _cachedOneShots.Values) + { + sound.Dispose(); + } + _cachedOneShots.Clear(); + } + } +} +#else +using Client.MirSounds.Libraries; using NAudio.Wave; using NAudio.Wave.SampleProviders; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; namespace Client.MirSounds { @@ -231,3 +484,4 @@ public static void Dispose() } } } +#endif diff --git a/Client/Platform/FNA/AssetResolver.cs b/Client/Platform/FNA/AssetResolver.cs new file mode 100644 index 000000000..04e1f14a1 --- /dev/null +++ b/Client/Platform/FNA/AssetResolver.cs @@ -0,0 +1,153 @@ +using System; +using System.IO; +using System.Diagnostics; +using System.Collections.Generic; +using Client.Platform; + +namespace Client.Platform.FNA +{ + public class AssetResolver : IAssetResolver + { + private static readonly Dictionary _vfsIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly string _transcodeCacheDir; + + static AssetResolver() + { + _transcodeCacheDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TranscodeCache"); + if (!Directory.Exists(_transcodeCacheDir)) + { + Directory.CreateDirectory(_transcodeCacheDir); + } + + // Build case-insensitive virtual filesystem index + BuildVfsIndex(AppDomain.CurrentDomain.BaseDirectory); + } + + private static void BuildVfsIndex(string rootDir) + { + try + { + var files = Directory.GetFiles(rootDir, "*", SearchOption.AllDirectories); + foreach (var file in files) + { + var normalized = Path.GetFullPath(file).Replace('\\', '/'); + _vfsIndex[normalized] = file; + } + } + catch (Exception ex) + { + Console.WriteLine($"VFS Indexing error: {ex}"); + } + } + + public string Resolve(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var normalizedInput = path.Replace('\\', '/'); + var fullPath = Path.GetFullPath(normalizedInput).Replace('\\', '/'); + if (_vfsIndex.TryGetValue(fullPath, out var resolvedPath)) + { + return resolvedPath; + } + + // Fallback to lowercased search if dynamic files are added at runtime + return path; + } + + public bool Exists(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var resolved = Resolve(path); + return File.Exists(resolved); + } + + public byte[] ReadAllBytes(string path) + { + var resolved = Resolve(path); + return File.ReadAllBytes(resolved); + } + + public Stream OpenRead(string path) + { + var resolved = Resolve(path); + return File.OpenRead(resolved); + } + + public string ResolveSound(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var resolvedPath = Resolve(path); + var extension = Path.GetExtension(resolvedPath).ToLower(); + + if (extension == ".wma") + { + return GetTranscodedOggPath(resolvedPath); + } + + return resolvedPath; + } + + private string GetTranscodedOggPath(string wmaPath) + { + var fileName = Path.GetFileNameWithoutExtension(wmaPath); + var oggPath = Path.Combine(_transcodeCacheDir, fileName + ".ogg"); + + // Bypasses transcoding if already cached + if (File.Exists(oggPath)) + { + return oggPath; + } + + // Transcode dynamically using ffmpeg if present + if (TryTranscode(wmaPath, oggPath)) + { + // Register the newly created file in the VFS index + var normalized = Path.GetFullPath(oggPath).Replace('\\', '/'); + _vfsIndex[normalized] = oggPath; + return oggPath; + } + + // Fallback gracefully to WAV if transcoding failed + var wavFallback = Path.ChangeExtension(wmaPath, ".wav"); + if (File.Exists(Resolve(wavFallback))) + { + return Resolve(wavFallback); + } + + return wmaPath; + } + + private bool TryTranscode(string inputPath, string outputPath) + { + try + { + var processStart = new ProcessStartInfo + { + FileName = "ffmpeg", + Arguments = $"-y -i \"{inputPath}\" -codec:a libvorbis -qscale:a 4 \"{outputPath}\"", + CreateNoWindow = true, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using (var process = Process.Start(processStart)) + { + process.WaitForExit(8000); // Max 8 seconds per track + if (process.ExitCode == 0 && File.Exists(outputPath)) + { + Console.WriteLine($"Transcode success: {Path.GetFileName(inputPath)} -> {Path.GetFileName(outputPath)}"); + return true; + } + } + } + catch + { + // Silence exception if ffmpeg is missing in user path + } + return false; + } + } +} diff --git a/Client/Platform/FNA/FNAEntry.cs b/Client/Platform/FNA/FNAEntry.cs new file mode 100644 index 000000000..1d5e2ec3b --- /dev/null +++ b/Client/Platform/FNA/FNAEntry.cs @@ -0,0 +1,270 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using Client.Platform; +using Client.MirScenes; +using Client.MirControls; +using Client.MirGraphics; +using Client.MirSounds; +using FontStashSharp; + +namespace Client.Platform.FNA +{ + public class FNAEntry : Game + { + public static FNAEntry Instance { get; private set; } + public GraphicsDeviceManager Graphics { get; } + public FNARenderer Renderer { get; private set; } + + + + private KeyboardState _prevKeyboardState; + private MouseState _prevMouseState; + private long _cleanTime; + + public FNAEntry() + { + Instance = this; + Graphics = new GraphicsDeviceManager(this) + { + PreferredBackBufferWidth = Settings.ScreenWidth, + PreferredBackBufferHeight = Settings.ScreenHeight, + IsFullScreen = Settings.FullScreen, + SynchronizeWithVerticalRetrace = true + }; + Graphics.PreparingDeviceSettings += (sender, e) => + { + e.GraphicsDeviceInformation.PresentationParameters.RenderTargetUsage = RenderTargetUsage.PreserveContents; + }; + + Content.RootDirectory = "Content"; + IsMouseVisible = true; + } + + protected override void Initialize() + { + Renderer = new FNARenderer(GraphicsDevice); + Renderer.Initialize(Settings.ScreenWidth, Settings.ScreenHeight, Settings.FullScreen); + DXManager.Renderer = Renderer; + + base.Initialize(); + + // Hook FNA's native TextInputEXT for text typing (handles full Chinese/IME inputs natively!) + TextInputEXT.TextInput += OnTextInput; + + // Load baseline configurations + _prevKeyboardState = Keyboard.GetState(); + _prevMouseState = Mouse.GetState(); + + // Set running state + CMain.Time = 0; + } + + protected override void LoadContent() + { + base.LoadContent(); + + // Initialize game managers + DXManager.Renderer = Renderer; + SoundManager.Create(); + } + + private void OnTextInput(char character) + { + if (MirScene.ActiveScene == null) return; + + var e = new MirKeyPressEventArgs(character); + MirScene.ActiveScene.OnKeyPress(e); + } + + protected override void Update(GameTime gameTime) + { + base.Update(gameTime); + + // Update global frame timings + CMain.Time = (long)gameTime.TotalGameTime.TotalMilliseconds; + + if (CMain.Time >= _cleanTime) + { + _cleanTime = CMain.Time + 1000; + DXManager.Clean(); + } + + // Process Network packets + Client.MirNetwork.Network.Process(); + + // Handle Poll-based Inputs + PollKeyboard(); + PollMouse(); + + // Update scenes & animations + if (MirScene.ActiveScene != null) + MirScene.ActiveScene.Process(); + + for (int i = 0; i < MirAnimatedControl.Animations.Count; i++) + MirAnimatedControl.Animations[i].UpdateOffSet(); + + for (int i = 0; i < MirAnimatedButton.Animations.Count; i++) + MirAnimatedButton.Animations[i].UpdateOffSet(); + } + + private void PollKeyboard() + { + if (MirScene.ActiveScene == null) return; + + var currState = Keyboard.GetState(); + var pressedKeys = currState.GetPressedKeys(); + + // Determine modifiers + var modifiers = MirKeys.None; + if (currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.LeftShift) || currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.RightShift)) + modifiers |= MirKeys.Shift; + if (currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.LeftControl) || currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.RightControl)) + modifiers |= MirKeys.Control; + if (currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.LeftAlt) || currState.IsKeyDown(Microsoft.Xna.Framework.Input.Keys.RightAlt)) + modifiers |= MirKeys.Alt; + + CMain.Shift = (modifiers & MirKeys.Shift) == MirKeys.Shift; + CMain.Ctrl = (modifiers & MirKeys.Control) == MirKeys.Control; + CMain.Alt = (modifiers & MirKeys.Alt) == MirKeys.Alt; + + if (Client.MirControls.MirControl.ActiveControl is Client.MirControls.MirTextBox textBox) + { + // Key Down events + foreach (var key in pressedKeys) + { + if (!_prevKeyboardState.IsKeyDown(key)) + { + var mirKey = (MirKeys)(int)key; + var e = new MirKeyEventArgs(mirKey, modifiers); + textBox.OnKeyDown(e); + } + } + + // Key Up events + foreach (var key in _prevKeyboardState.GetPressedKeys()) + { + if (!currState.IsKeyDown(key)) + { + var mirKey = (MirKeys)(int)key; + var e = new MirKeyEventArgs(mirKey, modifiers); + textBox.OnKeyUp(e); + } + } + + _prevKeyboardState = currState; + return; + } + + // Key Down events + foreach (var key in pressedKeys) + { + if (!_prevKeyboardState.IsKeyDown(key)) + { + var mirKey = (MirKeys)(int)key; + var e = new MirKeyEventArgs(mirKey, modifiers); + MirScene.ActiveScene.OnKeyDown(e); + } + } + + // Key Up events + foreach (var key in _prevKeyboardState.GetPressedKeys()) + { + if (!currState.IsKeyDown(key)) + { + var mirKey = (MirKeys)(int)key; + var e = new MirKeyEventArgs(mirKey, modifiers); + MirScene.ActiveScene.OnKeyUp(e); + } + } + + _prevKeyboardState = currState; + } + + private void PollMouse() + { + if (MirScene.ActiveScene == null) return; + + var currState = Mouse.GetState(); + CMain.MPoint = new System.Drawing.Point(currState.X, currState.Y); + + // Track Mouse Move + if (currState.X != _prevMouseState.X || currState.Y != _prevMouseState.Y) + { + var buttons = GetButtons(currState); + var e = new MirMouseEventArgs(buttons, 0, currState.X, currState.Y, 0); + MirScene.ActiveScene.OnMouseMove(e); + } + + // Track Mouse Scroll + if (currState.ScrollWheelValue != _prevMouseState.ScrollWheelValue) + { + var delta = currState.ScrollWheelValue - _prevMouseState.ScrollWheelValue; + var buttons = GetButtons(currState); + var e = new MirMouseEventArgs(buttons, 0, currState.X, currState.Y, delta); + MirScene.ActiveScene.OnMouseWheel(e); + } + + // Mouse Down & Up checks + CheckMouseButton(currState.LeftButton, _prevMouseState.LeftButton, MirMouseButtons.Left, currState); + CheckMouseButton(currState.RightButton, _prevMouseState.RightButton, MirMouseButtons.Right, currState); + CheckMouseButton(currState.MiddleButton, _prevMouseState.MiddleButton, MirMouseButtons.Middle, currState); + + _prevMouseState = currState; + } + + private void CheckMouseButton(ButtonState curr, ButtonState prev, MirMouseButtons button, MouseState state) + { + if (curr == ButtonState.Pressed && prev == ButtonState.Released) + { + var e = new MirMouseEventArgs(button, 1, state.X, state.Y, 0); + MirScene.ActiveScene.OnMouseDown(e); + } + else if (curr == ButtonState.Released && prev == ButtonState.Pressed) + { + var e = new MirMouseEventArgs(button, 1, state.X, state.Y, 0); + MirScene.ActiveScene.OnMouseClick(e); + MirScene.ActiveScene.OnMouseUp(e); + } + } + + private MirMouseButtons GetButtons(MouseState state) + { + var buttons = MirMouseButtons.None; + if (state.LeftButton == ButtonState.Pressed) buttons |= MirMouseButtons.Left; + if (state.RightButton == ButtonState.Pressed) buttons |= MirMouseButtons.Right; + if (state.MiddleButton == ButtonState.Pressed) buttons |= MirMouseButtons.Middle; + return buttons; + } + + protected override void Draw(GameTime gameTime) + { + if (Renderer == null) return; + + // Clear screen + Renderer.Clear(System.Drawing.Color.Black); + + // Execute scene rendering + if (MirScene.ActiveScene != null) + { + Renderer.BeginDraw(); + + MirScene.ActiveScene.Draw(); + + Renderer.EndDraw(); + } + + base.Draw(gameTime); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Renderer?.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/Client/Platform/FNA/FNAFontManager.cs b/Client/Platform/FNA/FNAFontManager.cs new file mode 100644 index 000000000..e775019eb --- /dev/null +++ b/Client/Platform/FNA/FNAFontManager.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using FontStashSharp; + +namespace Client.Platform.FNA +{ + public static class FNAFontManager + { + public static FontSystem FontSystem { get; private set; } + + static FNAFontManager() + { + FontSystem = new FontSystem(); + + // Look for standard high-quality TrueType fonts on Linux + string[] systemFontPaths = new[] + { + "/usr/share/fonts/google-droid-sans-fonts/DroidSans-Bold.ttf" + }; + + string resolvedFontPath = null; + foreach (var path in systemFontPaths) + { + if (File.Exists(path)) + { + resolvedFontPath = path; + break; + } + } + + if (resolvedFontPath != null) + { + try + { + FontSystem.AddFont(File.ReadAllBytes(resolvedFontPath)); + Console.WriteLine($"[FNAFontManager] Successfully loaded TrueType font: {resolvedFontPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[FNAFontManager] Error loading system font {resolvedFontPath}: {ex.Message}"); + } + } + else + { + Console.WriteLine("[FNAFontManager] Warning: No standard system TrueType fonts found! Dynamic text may fail to render."); + } + + // Look for CJK (Chinese, Japanese, Korean) fallback fonts on Linux + string[] chineseFontPaths = new[] + { + "/usr/share/fonts/fandol/FandolHei-Bold.otf" + }; + + foreach (var path in chineseFontPaths) + { + if (File.Exists(path)) + { + try + { + FontSystem.AddFont(File.ReadAllBytes(path)); + Console.WriteLine($"[FNAFontManager] Successfully loaded CJK fallback font: {path}"); + } + catch (Exception ex) + { + Console.WriteLine($"[FNAFontManager] Error loading CJK fallback font {path}: {ex.Message}"); + } + } + } + } + + public static SpriteFontBase GetFont(float size) + { + // System.Drawing.Font.Size is traditionally in Points (1/72 inch). + // FontStashSharp expects size in Pixels. + // At standard 96 DPI, 1 Point = 96/72 = 1.333... Pixels. + float pixelSize = size * (96f / 72f); + int roundedSize = (int)Math.Max(1, Math.Round(pixelSize)); + return FontSystem.GetFont(roundedSize); + } + } +} diff --git a/Client/Platform/FNA/FNARenderer.cs b/Client/Platform/FNA/FNARenderer.cs new file mode 100644 index 000000000..285fe2add --- /dev/null +++ b/Client/Platform/FNA/FNARenderer.cs @@ -0,0 +1,325 @@ +using System; +using System.Drawing; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Client.Platform; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +#if FNA +using TextureHandle = Microsoft.Xna.Framework.Graphics.Texture2D; +using MatrixType = Microsoft.Xna.Framework.Matrix; +#else +using TextureHandle = SlimDX.Direct3D9.Texture; +using MatrixType = SlimDX.Matrix; +#endif + +namespace Client.Platform.FNA +{ + public class FNARenderer : IGraphicsRenderer, IDisposable + { + public GraphicsDevice Device { get; private set; } + public SpriteBatch SpriteBatch { get; private set; } + public RenderTarget2D LightRenderTarget { get; private set; } + public BasicEffect BasicEffect { get; private set; } + + private MatrixType _transformMatrix = MatrixType.Identity; + private bool _hasTransform = false; + private Texture2D _whiteTexture; + private readonly BlendState _additiveBlendState; + private readonly BlendState _multiplyBlendState; + private readonly VertexPositionColor[] _radialLightVertices; + + public FNARenderer(GraphicsDevice device) + { + Device = device; + Device.PresentationParameters.RenderTargetUsage = RenderTargetUsage.PreserveContents; + SpriteBatch = new SpriteBatch(Device); + BasicEffect = new BasicEffect(Device) + { + VertexColorEnabled = true, + Projection = Matrix.CreateOrthographicOffCenter(0, device.Viewport.Width, device.Viewport.Height, 0, 0, 1) + }; + + _whiteTexture = new Texture2D(Device, 1, 1); + _whiteTexture.SetData(new[] { Microsoft.Xna.Framework.Color.White }); + + _additiveBlendState = new BlendState + { + ColorSourceBlend = Blend.SourceAlpha, + ColorDestinationBlend = Blend.One, + AlphaSourceBlend = Blend.SourceAlpha, + AlphaDestinationBlend = Blend.One + }; + + _multiplyBlendState = new BlendState + { + ColorSourceBlend = Blend.Zero, + ColorDestinationBlend = Blend.SourceColor, + AlphaSourceBlend = Blend.One, + AlphaDestinationBlend = Blend.Zero + }; + + _radialLightVertices = new VertexPositionColor[34]; + } + + public void DrawRectangle(System.Drawing.Rectangle rect, System.Drawing.Color color, float opacity) + { + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity; + var destRect = new Microsoft.Xna.Framework.Rectangle(rect.X, rect.Y, rect.Width, rect.Height); + SpriteBatch.Draw(_whiteTexture, destRect, xnaColor); + } + + public void Initialize(int width, int height, bool fullScreen) + { + Device.PresentationParameters.RenderTargetUsage = RenderTargetUsage.PreserveContents; + // Reset the render target when resolutions change + if (LightRenderTarget != null) + { + LightRenderTarget.Dispose(); + } + LightRenderTarget = new RenderTarget2D(Device, width, height, false, SurfaceFormat.Color, DepthFormat.None); + } + + public void BeginDraw() + { + if (_hasTransform) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + } + else + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + } + } + + public void EndDraw() + { + SpriteBatch.End(); + } + + public void Clear(System.Drawing.Color color) + { + Device.Clear(new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A)); + } + + public void SetViewport(int x, int y, int width, int height) + { + Device.Viewport = new Viewport(x, y, width, height); + BasicEffect.Projection = Matrix.CreateOrthographicOffCenter(0, width, height, 0, 0, 1); + } + + public void Draw(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color) + { + var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A); + var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); + + SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); + } + + public void DrawOpaque(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, float opacity) + { + var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity; + var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); + + SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); + } + + public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, float rate) + { + // Set additive/blended states for special FX + SpriteBatch.End(); + + if (_hasTransform) + SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState, null, null, null, null, _transformMatrix); + else + SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState); + + Draw(texture, sourceRect, position, color); + + SpriteBatch.End(); + + if (_hasTransform) + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + else + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + } + + public void DrawTinted(TextureHandle texture, TextureHandle maskTexture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, System.Drawing.Color tint) + { + // Simple multi-pass draw for tinted overlays + Draw(texture, sourceRect, position, color); + Draw(maskTexture, sourceRect, position, tint); + } + + public void RenderGPULights(List lights, System.Drawing.Color darkness) + { + if (darkness.R == 255 && darkness.G == 255 && darkness.B == 255 && darkness.A == 255 && (lights == null || lights.Count == 0)) + return; + + // Recreate render target if viewport dimensions changed (DPI, resolution change) + if (LightRenderTarget == null || LightRenderTarget.Width != Device.Viewport.Width || LightRenderTarget.Height != Device.Viewport.Height) + { + LightRenderTarget?.Dispose(); + LightRenderTarget = new RenderTarget2D(Device, Device.Viewport.Width, Device.Viewport.Height, false, SurfaceFormat.Color, DepthFormat.None); + } + + // End active SpriteBatch before switching render targets and drawing with BasicEffect + SpriteBatch.End(); + + // Bind Light Mask target + Device.SetRenderTarget(LightRenderTarget); + Device.Clear(new Microsoft.Xna.Framework.Color(darkness.R, darkness.G, darkness.B, darkness.A)); // Ambient dark baseline + + // Update basic effect parameters for clean 2D pixel space rendering + BasicEffect.World = Microsoft.Xna.Framework.Matrix.Identity; + BasicEffect.View = Microsoft.Xna.Framework.Matrix.Identity; + BasicEffect.Projection = Microsoft.Xna.Framework.Matrix.CreateOrthographicOffCenter(0, LightRenderTarget.Width, LightRenderTarget.Height, 0, -1, 1); + BasicEffect.TextureEnabled = false; + BasicEffect.VertexColorEnabled = true; + + // Set Additive Blending for stacking lights + Device.BlendState = _additiveBlendState; + Device.RasterizerState = RasterizerState.CullNone; + Device.DepthStencilState = DepthStencilState.None; + + if (lights != null && lights.Count > 0) + { + // Render each light as a mathematically interpolated GPU primitive + foreach (var light in lights) + { + DrawGPURadialLight(light.Center.X, light.Center.Y, light.Width / 2f, light.Height / 2f, light.Color); + } + } + + // Restore Main Surface + Device.SetRenderTarget(null); + + // Draw composite light mask onto main viewport with Multiplicative blending + SpriteBatch.Begin(SpriteSortMode.Immediate, _multiplyBlendState); + SpriteBatch.Draw(LightRenderTarget, Vector2.Zero, Microsoft.Xna.Framework.Color.White); + SpriteBatch.End(); + + // Re-bind default drawing state + if (_hasTransform) + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + else + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + } + + private void DrawGPURadialLight(float centerX, float centerY, float radiusX, float radiusY, System.Drawing.Color color) + { + const int segments = 32; + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A); + var outerColor = new Microsoft.Xna.Framework.Color(xnaColor.R, xnaColor.G, xnaColor.B, 0); + + var vertices = new VertexPositionColor[segments * 3]; + + for (int i = 0; i < segments; i++) + { + float angle1 = i * MathHelper.TwoPi / segments; + float angle2 = (i + 1) * MathHelper.TwoPi / segments; + + float x1 = centerX + radiusX * MathF.Cos(angle1); + float y1 = centerY + radiusY * MathF.Sin(angle1); + + float x2 = centerX + radiusX * MathF.Cos(angle2); + float y2 = centerY + radiusY * MathF.Sin(angle2); + + int idx = i * 3; + vertices[idx] = new VertexPositionColor(new Vector3(centerX, centerY, 0.0f), xnaColor); + vertices[idx + 1] = new VertexPositionColor(new Vector3(x1, y1, 0.0f), outerColor); + vertices[idx + 2] = new VertexPositionColor(new Vector3(x2, y2, 0.0f), outerColor); + } + + // Render primitive using GPU vertex interpolators + foreach (var pass in BasicEffect.CurrentTechnique.Passes) + { + pass.Apply(); + Device.DrawUserPrimitives(PrimitiveType.TriangleList, vertices, 0, segments); + } + } + + public TextureHandle CreateTexture(int width, int height) + { + return new Texture2D(Device, width, height, false, SurfaceFormat.Color); + } + + public TextureHandle LoadTexture(string path) + { + using (var image = SixLabors.ImageSharp.Image.Load(path)) + { + var texture = new Texture2D(Device, image.Width, image.Height, false, SurfaceFormat.Color); + var pixels = new Microsoft.Xna.Framework.Color[image.Width * image.Height]; + + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < accessor.Height; y++) + { + var row = accessor.GetRowSpan(y); + for (int x = 0; x < accessor.Width; x++) + { + var pixel = row[x]; + pixels[y * image.Width + x] = new Microsoft.Xna.Framework.Color(pixel.R, pixel.G, pixel.B, pixel.A); + } + } + }); + + texture.SetData(pixels); + return texture; + } + } + + public void SetTransform(MatrixType matrix) + { + _transformMatrix = matrix; + _hasTransform = true; + + // Re-apply viewport spritebatch if active + SpriteBatch.End(); + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + } + + public void ResetTransform() + { + _transformMatrix = MatrixType.Identity; + _hasTransform = false; + + SpriteBatch.End(); + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + } + + public void SetSurface(object surface) + { + // Bypassed: dynamic composite rendering used directly under FNA + } + + public void SetGrayscale(bool value) + { + // Grayscale rendering state handled in main pixel shaders + } + + public void SetOpacity(float opacity) + { + // Opacity drawing state integrated natively in spritebatch calls + } + + public void SetBlend(bool blend, float rate = 1f, Client.MirGraphics.BlendMode mode = Client.MirGraphics.BlendMode.Normal) + { + // Additive/alpha blend states managed inside shader render passes + } + + public void Dispose() + { + SpriteBatch?.Dispose(); + LightRenderTarget?.Dispose(); + BasicEffect?.Dispose(); + _whiteTexture?.Dispose(); + _additiveBlendState?.Dispose(); + _multiplyBlendState?.Dispose(); + } + } +} diff --git a/Client/Platform/FNA/FNATextRenderer.cs b/Client/Platform/FNA/FNATextRenderer.cs new file mode 100644 index 000000000..75c954800 --- /dev/null +++ b/Client/Platform/FNA/FNATextRenderer.cs @@ -0,0 +1,133 @@ +using System; +using System.Drawing; +using System.Collections.Generic; + +namespace Client.Platform.FNA +{ + public static class FNATextRenderer + { + public static Size MeasureText(Graphics g, string text, Font font) + { + if (string.IsNullOrEmpty(text)) + return Size.Empty; + + var spriteFont = FNAFontManager.GetFont(font.Size); + var size = spriteFont.MeasureString(text); + return new Size((int)Math.Ceiling(size.X), (int)Math.Ceiling(size.Y)); + } + + public static Size MeasureText(Graphics g, string text, Font font, Size proposedSize, System.Windows.Forms.TextFormatFlags flags) + { + if (string.IsNullOrEmpty(text)) + return Size.Empty; + + if ((flags & System.Windows.Forms.TextFormatFlags.WordBreak) == System.Windows.Forms.TextFormatFlags.WordBreak && proposedSize.Width > 0) + { + var spriteFont = FNAFontManager.GetFont(font.Size); + float singleLineHeight = spriteFont.MeasureString("A").Y; + + if (proposedSize.Height <= 0 || proposedSize.Height >= singleLineHeight * 1.5f) + { + string wrappedText = WrapText(spriteFont, text, proposedSize.Width); + var size = spriteFont.MeasureString(wrappedText); + return new Size((int)Math.Ceiling(size.X), (int)Math.Ceiling(size.Y)); + } + } + + return MeasureText(g, text, font); + } + + private static string WrapText(FontStashSharp.SpriteFontBase font, string text, float maxWidth) + { + if (maxWidth <= 0) + return text; + + string[] lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + List wrappedLines = new List(); + + foreach (string line in lines) + { + if (font.MeasureString(line).X <= maxWidth) + { + wrappedLines.Add(line); + continue; + } + + System.Text.StringBuilder currentLine = new System.Text.StringBuilder(); + string lastWord = ""; + + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + bool isCJK = (c >= 0x4E00 && c <= 0x9FFF) || + (c >= 0x3400 && c <= 0x4DBF) || + (c >= 0x3000 && c <= 0x303F) || + (c >= 0x3040 && c <= 0x309F) || + (c >= 0x30A0 && c <= 0x30FF) || + (c >= 0xFF00 && c <= 0xFFEF); + + if (isCJK || char.IsWhiteSpace(c)) + { + if (lastWord.Length > 0) + { + string test = currentLine.ToString() + lastWord; + if (font.MeasureString(test).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + } + currentLine.Append(lastWord); + lastWord = ""; + } + + string testChar = currentLine.ToString() + c; + if (font.MeasureString(testChar).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + if (!char.IsWhiteSpace(c)) + { + currentLine.Append(c); + } + } + else + { + currentLine.Append(c); + } + } + else + { + lastWord += c; + } + } + + if (lastWord.Length > 0) + { + string test = currentLine.ToString() + lastWord; + if (font.MeasureString(test).X > maxWidth) + { + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + currentLine.Clear(); + } + } + currentLine.Append(lastWord); + } + + if (currentLine.Length > 0) + { + wrappedLines.Add(currentLine.ToString().TrimEnd()); + } + } + + return string.Join("\n", wrappedLines); + } + } +} diff --git a/Client/Platform/FNA/NativeFallbackSDL.cs b/Client/Platform/FNA/NativeFallbackSDL.cs new file mode 100644 index 000000000..7322455c2 --- /dev/null +++ b/Client/Platform/FNA/NativeFallbackSDL.cs @@ -0,0 +1,93 @@ +using System; +using System.Runtime.InteropServices; + +namespace Client.Platform.FNA +{ + public static class NativeFallbackSDL + { + private const string LibSDL2 = "libSDL2-2.0.so.0"; + private const string LibFAudio = "libFAudio.so.0"; + + public static bool IsFallbackAvailable { get; private set; } + + static NativeFallbackSDL() + { + try + { + // Test binding linkage + var version = GetSDLVersion(); + IsFallbackAvailable = version != IntPtr.Zero; + if (IsFallbackAvailable) + { + Console.WriteLine("Antigravity Fallback Protocol: SDL2 native linkage verified successfully!"); + } + } + catch + { + IsFallbackAvailable = false; + Console.WriteLine("Antigravity Fallback Protocol: Native SDL2 libraries not found, continuing with default FNA bindings."); + } + } + + #region SDL2 Direct P/Invokes + [DllImport(LibSDL2, EntryPoint = "SDL_GetVersion", CallingConvention = CallingConvention.Cdecl)] + private static extern void SDL_GetVersion(out SDL_Version version); + + [DllImport(LibSDL2, EntryPoint = "SDL_GetWindowTitle", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr SDL_GetWindowTitle(IntPtr window); + + [DllImport(LibSDL2, EntryPoint = "SDL_SetWindowTitle", CallingConvention = CallingConvention.Cdecl)] + public static extern void SDL_SetWindowTitle(IntPtr window, [MarshalAs(UnmanagedType.LPStr)] string title); + + [DllImport(LibSDL2, EntryPoint = "SDL_GetMouseState", CallingConvention = CallingConvention.Cdecl)] + public static extern uint SDL_GetMouseState(out int x, out int y); + + [DllImport(LibSDL2, EntryPoint = "SDL_GetKeyboardState", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr SDL_GetKeyboardState(out int numkeys); + + [DllImport(LibSDL2, EntryPoint = "SDL_ShowSimpleMessageBox", CallingConvention = CallingConvention.Cdecl)] + public static extern int SDL_ShowSimpleMessageBox(uint flags, [MarshalAs(UnmanagedType.LPStr)] string title, [MarshalAs(UnmanagedType.LPStr)] string message, IntPtr window); + #endregion + + #region SDL Structural Types + [StructLayout(LayoutKind.Sequential)] + public struct SDL_Version + { + public byte major; + public byte minor; + public byte patch; + } + #endregion + + public static IntPtr GetSDLVersion() + { + try + { + SDL_GetVersion(out var version); + var ptr = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(version, ptr, false); + return ptr; + } + catch + { + return IntPtr.Zero; + } + } + + public static void ShowNativeError(string title, string message) + { + try + { + // SDL_MESSAGEBOX_ERROR = 0x00000010 + SDL_ShowSimpleMessageBox(0x00000010, title, message, IntPtr.Zero); + } + catch + { + // Fallback to standard Console error + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"[{title}] ERROR: {message}"); + Console.ResetColor(); + } + } + } +} diff --git a/Client/Platform/FNA/ProgramFNA.cs b/Client/Platform/FNA/ProgramFNA.cs new file mode 100644 index 000000000..8838bd43e --- /dev/null +++ b/Client/Platform/FNA/ProgramFNA.cs @@ -0,0 +1,71 @@ +using System; +using Client.Platform.FNA; + +namespace Client +{ + public static class Program + { + [STAThread] + public static void Main(string[] args) + { + + // Parse command-line flags + if (args.Length > 0) + { + foreach (var arg in args) + { + if (arg.ToLower() == "-tc") Settings.UseTestConfig = true; + } + } + + #if DEBUG + Settings.UseTestConfig = true; + #endif + + // Critical: Tell the packet system this is a client, not a server. + // Without this, received packets are deserialized as client-to-server + // packets instead of server-to-client packets, causing protocol errors. + Packet.IsServer = false; + + // Load client configuration (network IP/Port, graphics, sound, etc.) + Settings.Load(); + + // Run cross-platform headless update check before launching the game shell + if (Settings.P_Patcher) + { + Console.WriteLine("[Launcher] Auto-updater is enabled. Initializing headless patch check..."); + try + { + var patcher = new Launcher.HeadlessPatcher(); + + // Synchronously run the headless patcher to completion + bool patchSuccess = patcher.RunAsync().GetAwaiter().GetResult(); + if (!patchSuccess) + { + Console.WriteLine("[Launcher] Headless patching flow failed. Aborting game execution."); + Environment.Exit(1); + } + Console.WriteLine("[Launcher] Headless patching flow completed successfully."); + } + catch (Exception ex) + { + Console.WriteLine($"[Launcher] Critical error occurred during patching: {ex}"); + Environment.Exit(1); + } + } + + // 1. Initialize our virtual filesystem mapping (case-insensitivity support) + var assetResolver = new AssetResolver(); + + // 2. Register global platform hooks + Client.MirGraphics.DXManager.AssetResolver = assetResolver; + + // 3. Launch the cross-platform OpenGL game shell + using (var game = new FNAEntry()) + { + game.Run(); + } + } + } +} + diff --git a/Client/Platform/GlobalUsings.cs b/Client/Platform/GlobalUsings.cs new file mode 100644 index 000000000..ffa319d55 --- /dev/null +++ b/Client/Platform/GlobalUsings.cs @@ -0,0 +1,19 @@ +global using System.Drawing; +global using Keys = Client.Platform.MirKeys; +global using MouseButtons = Client.Platform.MirMouseButtons; +global using MouseEventArgs = Client.Platform.MirMouseEventArgs; +global using KeyEventArgs = Client.Platform.MirKeyEventArgs; +global using KeyPressEventArgs = Client.Platform.MirKeyPressEventArgs; +global using MouseEventHandler = Client.Platform.MirMouseEventHandler; +global using KeyEventHandler = Client.Platform.MirKeyEventHandler; +global using KeyPressEventHandler = Client.Platform.MirKeyPressEventHandler; + +#if FNA +global using Vector2 = Microsoft.Xna.Framework.Vector2; +global using Vector3 = Microsoft.Xna.Framework.Vector3; +global using Matrix = Microsoft.Xna.Framework.Matrix; +global using Texture = Microsoft.Xna.Framework.Graphics.Texture2D; +global using TextFormatFlags = System.Windows.Forms.TextFormatFlags; +global using TextRenderer = Client.Platform.FNA.FNATextRenderer; +global using SystemInformation = System.Windows.Forms.SystemInformation; +#endif diff --git a/Client/Platform/IAssetResolver.cs b/Client/Platform/IAssetResolver.cs new file mode 100644 index 000000000..62c01548e --- /dev/null +++ b/Client/Platform/IAssetResolver.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace Client.Platform +{ + public interface IAssetResolver + { + string Resolve(string path); + bool Exists(string path); + byte[] ReadAllBytes(string path); + Stream OpenRead(string path); + string ResolveSound(string path); // Returns transcoded path if needed (.wma -> .ogg) + } +} diff --git a/Client/Platform/IGraphicsRenderer.cs b/Client/Platform/IGraphicsRenderer.cs new file mode 100644 index 000000000..c1a598cc7 --- /dev/null +++ b/Client/Platform/IGraphicsRenderer.cs @@ -0,0 +1,56 @@ +using System; +using System.Drawing; +using System.Collections.Generic; + +#if FNA +using TextureHandle = Microsoft.Xna.Framework.Graphics.Texture2D; +using MatrixType = Microsoft.Xna.Framework.Matrix; +#else +using TextureHandle = SlimDX.Direct3D9.Texture; +using MatrixType = SlimDX.Matrix; +#endif + +namespace Client.Platform +{ + public interface IGraphicsRenderer + { + void Initialize(int width, int height, bool fullScreen); + void BeginDraw(); + void EndDraw(); + void Clear(Color color); + void SetViewport(int x, int y, int width, int height); + + void Draw(TextureHandle texture, Rectangle sourceRect, System.Drawing.Point position, Color color); + void DrawOpaque(TextureHandle texture, Rectangle sourceRect, System.Drawing.Point position, Color color, float opacity); + void DrawBlend(TextureHandle texture, Rectangle sourceRect, System.Drawing.Point position, Color color, float rate); + void DrawTinted(TextureHandle texture, TextureHandle maskTexture, Rectangle sourceRect, System.Drawing.Point position, Color color, Color tint); + void DrawRectangle(Rectangle rect, Color color, float opacity); + + // Dynamic GPU-driven radial lights rendering + void RenderGPULights(List lights, Color darkness); + + TextureHandle CreateTexture(int width, int height); + TextureHandle LoadTexture(string path); + + void SetTransform(MatrixType matrix); + void ResetTransform(); + + void SetSurface(object surface); + void SetGrayscale(bool value); + void SetOpacity(float opacity); +#if FNA + void SetBlend(bool blend, float rate = 1f, Client.MirGraphics.BlendMode mode = Client.MirGraphics.BlendMode.Normal); +#else + void SetBlend(bool blend, float rate = 1f, global::BlendMode mode = global::BlendMode.NORMAL); +#endif + } + + public class MirLightSource + { + public System.Drawing.Point Center { get; set; } + public int Radius { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public Color Color { get; set; } + } +} diff --git a/Client/Platform/MirInputTypes.cs b/Client/Platform/MirInputTypes.cs new file mode 100644 index 000000000..9bf30d4ea --- /dev/null +++ b/Client/Platform/MirInputTypes.cs @@ -0,0 +1,547 @@ +using System; + +namespace Client.Platform +{ + [Flags] + public enum MirMouseButtons + { + None = 0, + Left = 1048576, + Right = 2097152, + Middle = 4194304 + } + + public enum MirKeys + { + None = 0, + LButton = 1, + RButton = 2, + Cancel = 3, + MButton = 4, + Back = 8, + Tab = 9, + Clear = 12, + Enter = 13, + Return = 13, + ShiftKey = 16, + ControlKey = 17, + Menu = 18, // Alt + Pause = 19, + Capital = 20, // Caps Lock + CapsLock = 20, + Escape = 27, + Space = 32, + Prior = 33, // Page Up + PageUp = 33, + Next = 34, // Page Down + PageDown = 34, + End = 35, + Home = 36, + Left = 37, + Up = 38, + Right = 39, + Down = 40, + Select = 41, + Print = 42, + Execute = 43, + Snapshot = 44, // Print Screen + PrintScreen = 44, + Insert = 45, + Delete = 46, + Help = 47, + D0 = 48, + D1 = 49, + D2 = 50, + D3 = 51, + D4 = 52, + D5 = 53, + D6 = 54, + D7 = 55, + D8 = 56, + D9 = 57, + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + LWin = 91, + RWin = 92, + Apps = 93, + NumPad0 = 96, + NumPad1 = 97, + NumPad2 = 98, + NumPad3 = 99, + NumPad4 = 100, + NumPad5 = 101, + NumPad6 = 102, + NumPad7 = 103, + NumPad8 = 104, + NumPad9 = 105, + Multiply = 106, + Add = 107, + Separator = 108, + Subtract = 109, + Decimal = 110, + Divide = 111, + F1 = 112, + F2 = 113, + F3 = 114, + F4 = 115, + F5 = 116, + F6 = 117, + F7 = 118, + F8 = 119, + F9 = 120, + F10 = 121, + F11 = 122, + F12 = 123, + F13 = 124, + F14 = 125, + F15 = 126, + F16 = 127, + F17 = 128, + F18 = 129, + F19 = 130, + F20 = 131, + F21 = 132, + F22 = 133, + F23 = 134, + F24 = 135, + NumLock = 144, + Scroll = 145, + LShiftKey = 160, + RShiftKey = 161, + LControlKey = 162, + RControlKey = 163, + LMenu = 164, + RMenu = 165, + OemSemicolon = 186, + Oem1 = 186, + OemPlus = 187, + OemComma = 188, + OemMinus = 189, + OemPeriod = 190, + OemQuestion = 191, + Oem2 = 191, + Oemtilde = 192, + Oem3 = 192, + OemOpenBrackets = 219, + Oem4 = 219, + OemPipe = 220, + Oem5 = 220, + OemCloseBrackets = 221, + Oem6 = 221, + OemQuotes = 222, + Oem7 = 222, + Oem8 = 223, + // Modifier flags + Shift = 65536, + Control = 131072, + Alt = 262144 + } + + public class MirMouseEventArgs : EventArgs + { + public MirMouseButtons Button { get; } + public int Clicks { get; } + public int X { get; } + public int Y { get; } + public int Delta { get; } + public System.Drawing.Point Location => new System.Drawing.Point(X, Y); + + public MirMouseEventArgs(MirMouseButtons button, int clicks, int x, int y, int delta) + { + Button = button; + Clicks = clicks; + X = x; + Y = y; + Delta = delta; + } + } + + public class MirKeyEventArgs : EventArgs + { + public MirKeys KeyCode { get; } + public bool Handled { get; set; } + public MirKeys Modifiers { get; } + + public bool Shift => (Modifiers & MirKeys.Shift) == MirKeys.Shift; + public bool Control => (Modifiers & MirKeys.Control) == MirKeys.Control; + public bool Alt => (Modifiers & MirKeys.Alt) == MirKeys.Alt; + + public MirKeyEventArgs(MirKeys keyCode, MirKeys modifiers = MirKeys.None) + { + KeyCode = keyCode; + Modifiers = modifiers; + } + } + + public class MirKeyPressEventArgs : EventArgs + { + public char KeyChar { get; set; } + public bool Handled { get; set; } + + public MirKeyPressEventArgs(char keyChar) + { + KeyChar = keyChar; + } + } + + public delegate void MirMouseEventHandler(object sender, MirMouseEventArgs e); + public delegate void MirKeyEventHandler(object sender, MirKeyEventArgs e); + public delegate void MirKeyPressEventHandler(object sender, MirKeyPressEventArgs e); +} + +#if WINDOWS +namespace Client.Platform +{ + public static class InputTranslation + { + public static MirKeys ToNeutral(this System.Windows.Forms.Keys keys) + { + return (MirKeys)(int)keys; + } + + public static MirMouseButtons ToNeutral(this System.Windows.Forms.MouseButtons button) + { + return (MirMouseButtons)(int)button; + } + + public static MirKeyEventArgs ToNeutral(this System.Windows.Forms.KeyEventArgs e) + { + var modifiers = MirKeys.None; + if (e.Shift) modifiers |= MirKeys.Shift; + if (e.Control) modifiers |= MirKeys.Control; + if (e.Alt) modifiers |= MirKeys.Alt; + return new MirKeyEventArgs(e.KeyCode.ToNeutral(), modifiers) { Handled = e.Handled }; + } + + public static MirMouseEventArgs ToNeutral(this System.Windows.Forms.MouseEventArgs e) + { + return new MirMouseEventArgs(e.Button.ToNeutral(), e.Clicks, e.X, e.Y, e.Delta); + } + + public static MirKeyPressEventArgs ToNeutral(this System.Windows.Forms.KeyPressEventArgs e) + { + return new MirKeyPressEventArgs(e.KeyChar) { Handled = e.Handled }; + } + } +} +#else +namespace System.Drawing +{ + public enum FontStyle + { + Regular = 0, + Bold = 1, + Italic = 2, + Underline = 4, + Strikeout = 8, + } + + public enum GraphicsUnit + { + World = 0, + Display = 1, + Pixel = 2, + Point = 3, + Inch = 4, + Document = 5, + Millimeter = 6, + } + + public class Font : IDisposable + { + public string Name { get; } + public float Size { get; } + public FontStyle Style { get; } + public GraphicsUnit Unit { get; } + + public Font(string name, float size) + { + Name = name; + Size = size; + Unit = GraphicsUnit.Point; + } + + public Font(string name, float size, FontStyle style) + { + Name = name; + Size = size; + Style = style; + Unit = GraphicsUnit.Point; + } + + public Font(string name, float size, FontStyle style, GraphicsUnit unit) + { + Name = name; + Size = size; + Style = style; + Unit = unit; + } + + public int Height => (int)Math.Ceiling(GetHeight(96f)); + + public float GetHeight(float dpi) + { + return Size * (dpi / 72.0f); + } + + public void Dispose() { } + } + + public class Bitmap : IDisposable + { + public int Width { get; } + public int Height { get; } + + public Bitmap(int width, int height) + { + Width = width; + Height = height; + } + + public Bitmap(int width, int height, Imaging.PixelFormat format) + { + Width = width; + Height = height; + } + + public Bitmap(string path) + { + Width = 1; + Height = 1; + } + + public Bitmap(System.IO.Stream stream) + { + Width = 1; + Height = 1; + } + + public Imaging.BitmapData LockBits(Rectangle rect, Imaging.ImageLockMode flags, Imaging.PixelFormat format) + { + return new Imaging.BitmapData(); + } + + public void UnlockBits(Imaging.BitmapData data) { } + + public void Dispose() { } + } + + public class Graphics : IDisposable + { + public static Graphics FromImage(Bitmap bitmap) => new Graphics(); + + public float DpiX => 96f; + public float DpiY => 96f; + + public SizeF MeasureString(string text, Font font) + { + if (string.IsNullOrEmpty(text)) return SizeF.Empty; + var vec = Client.Platform.FNA.FNAFontManager.GetFont(font.Size).MeasureString(text); + return new SizeF(vec.X, vec.Y); + } + + public SizeF MeasureString(string text, Font font, int width) + { + if (string.IsNullOrEmpty(text)) return SizeF.Empty; + var vec = Client.Platform.FNA.FNAFontManager.GetFont(font.Size).MeasureString(text); + if (vec.X <= width) + { + return new SizeF(vec.X, vec.Y); + } + int lines = (int)Math.Ceiling(vec.X / width); + if (lines < 1) lines = 1; + return new SizeF(width, vec.Y * lines); + } + + public SizeF MeasureString(string text, Font font, SizeF layoutArea) + { + return MeasureString(text, font, (int)layoutArea.Width); + } + + public void DrawString(string text, Font font, Brush brush, RectangleF layoutRectangle) { } + + public void Dispose() { } + } + + public abstract class Brush : IDisposable + { + public void Dispose() { } + } + + public class SolidBrush : Brush + { + public Color Color { get; } + public SolidBrush(Color color) => Color = color; + } +} + +namespace System.Drawing.Imaging +{ + public enum PixelFormat + { + Format32bppArgb, + } + + public enum ImageLockMode + { + ReadOnly, + WriteOnly, + ReadWrite, + } + + public class BitmapData + { + public IntPtr Scan0 => IntPtr.Zero; + } +} + +namespace System.Windows.Forms +{ + [Flags] + public enum TextFormatFlags + { + Default = 0, + WordBreak = 16, + TextBoxControl = 512, + HorizontalCenter = 1, + VerticalCenter = 4, + SingleLine = 32, + NoPadding = 268435456, + NoPrefix = 2048, + ExpandTabs = 64, + Left = 0, + Right = 2, + Top = 0, + Bottom = 8, + RightToLeft = 131072, + } + + public static class SystemInformation + { + public static int MouseWheelScrollDelta => 120; + } + + public static class TextRenderer + { + public static System.Drawing.Size MeasureText(System.Drawing.Graphics graphics, string text, System.Drawing.Font font) + { + var size = graphics.MeasureString(text, font); + return new System.Drawing.Size((int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height)); + } + + public static System.Drawing.Size MeasureText(System.Drawing.Graphics graphics, string text, System.Drawing.Font font, System.Drawing.Size proposedSize, TextFormatFlags flags) + { + var size = graphics.MeasureString(text, font, proposedSize.Width); + return new System.Drawing.Size((int)Math.Ceiling(size.Width), (int)Math.Ceiling(size.Height)); + } + + public static void DrawText(System.Drawing.Graphics graphics, string text, System.Drawing.Font font, System.Drawing.Rectangle bounds, System.Drawing.Color color, TextFormatFlags flags) + { + using (var brush = new System.Drawing.SolidBrush(color)) + { + graphics.DrawString(text, font, brush, bounds); + } + } + } +} + +namespace SlimDX +{ + public class SlimDXStub { } +} +namespace SlimDX.Direct3D9 +{ + public class D3D9Stub { } + public enum RenderState + { + SourceBlend, + DestinationBlend + } + public enum Blend + { + SourceAlpha, + InverseSourceAlpha + } + [Flags] + public enum SpriteFlags + { + None = 0, + AlphaBlend = 1 + } +} + +namespace Client +{ + public static class CMain + { + public static long Time; + public static bool Shift; + public static bool Ctrl; + public static bool Alt; + public static System.Drawing.Point MPoint; + public static bool Tilde; + public static MouseCursor CurrentCursor = MouseCursor.None; + public static void SetMouseCursor(MouseCursor cursor) { CurrentCursor = cursor; } + + public static bool IsKeyLocked(Client.Platform.MirKeys key) => false; + + public static DateTime Now => DateTime.Now; + public static Random Random = new Random(); + public static bool SpellTargetLock; + public static long PingTime; + public static string DebugText = string.Empty; + public static Client.MirControls.MirLabel DebugBaseLabel; + public static Client.MirControls.MirLabel HintBaseLabel; + public static uint BytesReceived; + public static uint BytesSent; + public static long NextPing; + + public static KeyBindSettings InputKeys = new KeyBindSettings(); + + public static void CMain_KeyUp(object sender, Client.Platform.MirKeyEventArgs e) { } + public static void CMain_KeyDown(object sender, Client.Platform.MirKeyEventArgs e) { } + + public static void SaveError(string ex) + { + try + { + System.IO.File.AppendAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Error.txt"), $"{DateTime.Now}: {ex}{Environment.NewLine}"); + } + catch { } + } + + public static void SetResolution(int width, int height) + { + // Handled dynamically under Linux via FNA GraphicsDeviceManager + } + + private static System.Drawing.Bitmap _dummyBmp = new System.Drawing.Bitmap(1, 1); + public static System.Drawing.Graphics Graphics = System.Drawing.Graphics.FromImage(_dummyBmp); + } +} +#endif + diff --git a/Client/Platform/MonoGameCompat/MonoGameCompat.csproj b/Client/Platform/MonoGameCompat/MonoGameCompat.csproj new file mode 100644 index 000000000..185d791de --- /dev/null +++ b/Client/Platform/MonoGameCompat/MonoGameCompat.csproj @@ -0,0 +1,15 @@ + + + net10.0 + MonoGame.Framework + 3.8.0.1641 + 3.8.0.1641 + disable + disable + true + + + + + + diff --git a/Client/Platform/MonoGameCompat/TypeForwarders.cs b/Client/Platform/MonoGameCompat/TypeForwarders.cs new file mode 100644 index 000000000..63b63bbdb --- /dev/null +++ b/Client/Platform/MonoGameCompat/TypeForwarders.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; + +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Point))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Vector2))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Vector3))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Vector4))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Rectangle))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Color))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Matrix))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.Texture))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.Texture2D))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.GraphicsDevice))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.SpriteEffects))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.RenderTarget2D))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.SurfaceFormat))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.PresentationParameters))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.GraphicsResource))] +[assembly: TypeForwardedTo(typeof(Microsoft.Xna.Framework.Graphics.Viewport))] diff --git a/Client/Program.cs b/Client/Program.cs index 4c931c541..feae398cf 100644 --- a/Client/Program.cs +++ b/Client/Program.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using Launcher; using System.Runtime.InteropServices; using System.Runtime.CompilerServices; @@ -69,7 +69,8 @@ private static bool UpdatePatcher() { try { - const string fromName = @".\AutoPatcher.gz", toName = @".\AutoPatcher.exe"; + string fromName = Path.Combine(AppContext.BaseDirectory, "AutoPatcher.gz"); + string toName = Path.Combine(AppContext.BaseDirectory, "AutoPatcher.exe"); if (!File.Exists(fromName)) return false; Process[] processes = Process.GetProcessesByName("AutoPatcher"); diff --git a/Client/Resolution/DisplayResolutions.cs b/Client/Resolution/DisplayResolutions.cs index 67e91ee77..a51c5e6a9 100644 --- a/Client/Resolution/DisplayResolutions.cs +++ b/Client/Resolution/DisplayResolutions.cs @@ -1,10 +1,75 @@ -using System; +#if FNA +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework.Graphics; + +namespace Client.Resolution +{ + internal static class DisplayResolutions + { + internal static List DisplaySupportedResolutions = new List(); + + internal static bool GetDisplayResolutions() + { + try + { + var supportedResolutions = Enum.GetNames(typeof(eSupportedResolution)); + List list = new(); + + // Query resolutions from FNA/SDL's native cross-platform GraphicsAdapter + foreach (var mode in GraphicsAdapter.DefaultAdapter.SupportedDisplayModes) + { + string displayResolution = $"w{mode.Width}h{mode.Height}"; + if (supportedResolutions.Contains(displayResolution) && !list.Contains(displayResolution)) + { + list.Add(displayResolution); + } + } + + if (list.Count > 0) + { + foreach (string displayResolution in list) + { + if (Enum.TryParse(displayResolution, true, out var resolution)) + { + DisplaySupportedResolutions.Add(resolution); + } + } + } + + return DisplaySupportedResolutions.Count > 0; + } + catch + { + return false; + } + } + + internal static bool IsSupported(int resolution) + { + return IsSupported(resolution.ToString()); + } + + internal static bool IsSupported(string resolution) + { + if (!Enum.TryParse(resolution, true, out var res)) + { + return false; + } + return Enum.IsDefined(typeof(eSupportedResolution), res); + } + } +} +#else +using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; +using System.Windows.Forms; namespace Client.Resolution { @@ -130,3 +195,4 @@ internal struct DEVMODE } } } +#endif diff --git a/Client/Settings.cs b/Client/Settings.cs index b1dfdd178..c896c4254 100644 --- a/Client/Settings.cs +++ b/Client/Settings.cs @@ -1,4 +1,4 @@ -using Client.MirSounds; +using Client.MirSounds; namespace Client { @@ -7,8 +7,8 @@ class Settings public const long CleanDelay = 600000; public static int ScreenWidth = 1024, ScreenHeight = 768; - private static InIReader Reader = new InIReader(@".\Mir2Config.ini"); - private static InIReader QuestTrackingReader = new InIReader(Path.Combine(UserDataPath, @".\QuestTracking.ini")); + private static InIReader Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "Mir2Config.ini")); + private static InIReader QuestTrackingReader = new InIReader(Path.Combine(UserDataPath, "QuestTracking.ini")); private static bool _useTestConfig; public static bool UseTestConfig @@ -21,46 +21,46 @@ public static bool UseTestConfig { if (value == true) { - Reader = new InIReader(@".\Mir2Test.ini"); + Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "Mir2Test.ini")); } _useTestConfig = value; } } - public const string DataPath = @".\Data\", - MapPath = @".\Map\", - SoundPath = @".\Sound\", - ExtraDataPath = @".\Data\Extra\", - ShadersPath = @".\Data\Shaders\", - MonsterPath = @".\Data\Monster\", - GatePath = @".\Data\Gate\", - FlagPath = @".\Data\Flag\", - SiegePath = @".\Data\Siege\", - NPCPath = @".\Data\NPC\", - CArmourPath = @".\Data\CArmour\", - CWeaponPath = @".\Data\CWeapon\", - CWeaponEffectPath = @".\Data\CWeaponEffect\", - CHairPath = @".\Data\CHair\", - AArmourPath = @".\Data\AArmour\", - AWeaponPath = @".\Data\AWeapon\", - AHairPath = @".\Data\AHair\", - ARArmourPath = @".\Data\ARArmour\", - ARWeaponPath = @".\Data\ARWeapon\", - ARHairPath = @".\Data\ARHair\", - CHumEffectPath = @".\Data\CHumEffect\", - AHumEffectPath = @".\Data\AHumEffect\", - ARHumEffectPath = @".\Data\ARHumEffect\", - MountPath = @".\Data\Mount\", - FishingPath = @".\Data\Fishing\", - PetsPath = @".\Data\Pet\", - TransformPath = @".\Data\Transform\", - TransformMountsPath = @".\Data\TransformRide2\", - TransformEffectPath = @".\Data\TransformEffect\", - TransformWeaponEffectPath = @".\Data\TransformWeaponEffect\", - MouseCursorPath = @".\Data\Cursors\", - ResourcePath = @".\DirectX\", - UserDataPath = @".\Data\UserData\", - DbLanguageJsonPath = @".\DbLanguage.json"; + public const string DataPath = @"./Data/", + MapPath = @"./Map/", + SoundPath = @"./Sound/", + ExtraDataPath = @"./Data/Extra/", + ShadersPath = @"./Data/Shaders/", + MonsterPath = @"./Data/Monster/", + GatePath = @"./Data/Gate/", + FlagPath = @"./Data/Flag/", + SiegePath = @"./Data/Siege/", + NPCPath = @"./Data/NPC/", + CArmourPath = @"./Data/CArmour/", + CWeaponPath = @"./Data/CWeapon/", + CWeaponEffectPath = @"./Data/CWeaponEffect/", + CHairPath = @"./Data/CHair/", + AArmourPath = @"./Data/AArmour/", + AWeaponPath = @"./Data/AWeapon/", + AHairPath = @"./Data/AHair/", + ARArmourPath = @"./Data/ARArmour/", + ARWeaponPath = @"./Data/ARWeapon/", + ARHairPath = @"./Data/ARHair/", + CHumEffectPath = @"./Data/CHumEffect/", + AHumEffectPath = @"./Data/AHumEffect/", + ARHumEffectPath = @"./Data/ARHumEffect/", + MountPath = @"./Data/Mount/", + FishingPath = @"./Data/Fishing/", + PetsPath = @"./Data/Pet/", + TransformPath = @"./Data/Transform/", + TransformMountsPath = @"./Data/TransformRide2/", + TransformEffectPath = @"./Data/TransformEffect/", + TransformWeaponEffectPath = @"./Data/TransformWeaponEffect/", + MouseCursorPath = @"./Data/Cursors/", + ResourcePath = @"./DirectX/", + UserDataPath = @"./Data/UserData/", + DbLanguageJsonPath = @"./DbLanguage.json"; //Logs public static bool LogErrors = true; @@ -196,7 +196,11 @@ public static bool public static string P_Password = string.Empty; public static string P_ServerName = string.Empty; public static string P_BrowserAddress = "https://www.lomcn.org/mir2-patchsite/"; +#if !FNA public static string P_Client = Application.StartupPath + "\\"; +#else + public static string P_Client = AppContext.BaseDirectory + "/"; +#endif public static bool P_AutoStart = false; public static int P_Concurrency = 1; @@ -317,7 +321,7 @@ public static void Load() try { - string languageDirectory = @".\Localization\"; + string languageDirectory = Path.Combine(AppContext.BaseDirectory, "Localization"); if (!Directory.Exists(languageDirectory)) { Directory.CreateDirectory(languageDirectory); diff --git a/Client/Utils/BrowserHelper.cs b/Client/Utils/BrowserHelper.cs index 89bafa29f..f622dd8d9 100644 --- a/Client/Utils/BrowserHelper.cs +++ b/Client/Utils/BrowserHelper.cs @@ -1,10 +1,14 @@ -using Microsoft.Win32; +using System; using System.Diagnostics; +#if !FNA +using Microsoft.Win32; +#endif namespace Client.Utils { public class BrowserHelper { +#if !FNA private static void OpenChrometBrowser(string url) { try @@ -121,5 +125,30 @@ public static void OpenDefaultBrowser(string url) OpenIetBrowser(url); } } +#else + public static void OpenDefaultBrowser(string url) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "xdg-open", + Arguments = url, + UseShellExecute = true + }); + } + catch + { + try + { + Process.Start("open", url); // MacOS fallback + } + catch + { + // Fail silently + } + } + } +#endif } } diff --git a/Client/Utils/HeadlessPatcher.cs b/Client/Utils/HeadlessPatcher.cs new file mode 100644 index 000000000..af94b568c --- /dev/null +++ b/Client/Utils/HeadlessPatcher.cs @@ -0,0 +1,682 @@ +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Client; +using Client.Utils; + +namespace Launcher +{ + public class PatcherProgressEventArgs : EventArgs + { + public string CurrentFileName { get; set; } + public int FilesDownloaded { get; set; } + public int TotalFiles { get; set; } + public long BytesDownloaded { get; set; } + public long TotalBytes { get; set; } + public double SpeedBytesPerSecond { get; set; } + public string StatusMessage { get; set; } + } + + public class HeadlessPatcher + { + public event EventHandler ProgressChanged; + + private static readonly HttpClient _httpClient; + + private long _completedBytes = 0; + private long _totalBytesToDownload = 0; + private int _totalFilesToDownload = 0; + private int _filesDownloadedCount = 0; + private Stopwatch _downloadStopwatch; + private long _lastConsoleReportTime = 0; + private readonly object _consoleLock = new object(); + + static HeadlessPatcher() + { + // Upgrade HTTP stack: Use SocketsHttpHandler to support connection pooling and compression. + var handler = new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + PooledConnectionLifetime = TimeSpan.FromMinutes(5) + }; + + _httpClient = new HttpClient(handler) + { + DefaultRequestVersion = HttpVersion.Version20, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower, + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// Run the full headless patching flow asynchronously. + /// + public async Task RunAsync(CancellationToken cancellationToken = default) + { + // Step 1: Self-Update Check + if (CheckSelfUpdate()) + { + // Self-update triggered process restart; abort this execution + return false; + } + + // Step 2: Initialization + if (!Settings.P_Patcher) + { + LogProgress(null, 0, 0, 0, 0, 0, "Patcher is disabled in settings. Skipping update flow."); + return true; + } + + PurgeOldBackups(); + + string tempDirPath = Path.Combine(Settings.P_Client, ".patch_temp"); + + try + { + // Step 3: Fetch the Manifest + List manifestList = await FetchManifestAsync(cancellationToken).ConfigureAwait(false); + if (manifestList == null || manifestList.Count == 0) + { + LogProgress(null, 0, 0, 0, 0, 0, "No files found in patch manifest."); + return true; + } + + // Step 4: Local Validation + List downloadQueue = ValidateLocalFiles(manifestList, out _totalBytesToDownload); + _totalFilesToDownload = downloadQueue.Count; + + if (_totalFilesToDownload == 0) + { + LogProgress(null, 0, 0, 0, 0, 0, "Game client is up-to-date!"); + + // Clean obsolete files even if no downloads are needed + if (Settings.P_Patcher) // Mimic AMain clean behavior + { + CleanUpObsoleteFiles(manifestList); + } + return true; + } + + LogProgress(null, 0, _totalFilesToDownload, 0, _totalBytesToDownload, 0, + $"Starting update: {_totalFilesToDownload} files to download ({_totalBytesToDownload / 1024 / 1024} MB)..."); + + // Prepare temp directory: do not delete existing files to support resuming + if (!Directory.Exists(tempDirPath)) + { + Directory.CreateDirectory(tempDirPath); + } + + // Step 5: Concurrent Downloading & Decompression + _downloadStopwatch = Stopwatch.StartNew(); + + var options = new ParallelOptions + { + MaxDegreeOfParallelism = Settings.P_Concurrency > 0 ? Settings.P_Concurrency : 1, + CancellationToken = cancellationToken + }; + + await Parallel.ForEachAsync(downloadQueue, options, async (fileInfo, ct) => + { + await DownloadFileAsync(fileInfo, tempDirPath, ct).ConfigureAwait(false); + }).ConfigureAwait(false); + + _downloadStopwatch.Stop(); + + // Step 6: Atomic Move Strategy (Move temp files to destination) + LogProgress(null, _filesDownloadedCount, _totalFilesToDownload, _completedBytes, _totalBytesToDownload, 0, + "All downloads successfully completed. Applying file updates..."); + + foreach (var fileInfo in downloadQueue) + { + string localRelative = NormalizeLocalPath(fileInfo.FileName); + string sourcePath = Path.Combine(tempDirPath, localRelative); + string destPath = Path.Combine(Settings.P_Client, localRelative); + + // Perform strict integrity check on temp files before swap + if (!File.Exists(sourcePath)) + { + throw new FileNotFoundException($"Downloaded temp file was not found: {sourcePath}"); + } + long actualSize = new FileInfo(sourcePath).Length; + if (actualSize != fileInfo.Length) + { + throw new InvalidDataException($"Integrity check failed: Temp file size ({actualSize}) does not match manifest size ({fileInfo.Length}) for {fileInfo.FileName}."); + } + + // Apply Atomic Swap Strategy (Handles running / locked process files safely) + PerformAtomicSwap(sourcePath, destPath); + + // Post-Processing: Set creation & write time to match the server timestamp + File.SetLastWriteTime(destPath, fileInfo.Creation); + } + + // Clean up temp directory + if (Directory.Exists(tempDirPath)) + { + Directory.Delete(tempDirPath, true); + } + + // Step 7: Clean Up Obsolete Files + CleanUpObsoleteFiles(manifestList); + + LogProgress(null, _totalFilesToDownload, _totalFilesToDownload, _totalBytesToDownload, _totalBytesToDownload, 0, + "Update completed successfully! Client is up to date."); + + return true; + } + catch (Exception ex) + { + LogProgress(null, 0, 0, 0, 0, 0, $"Update failed: {ex.Message}"); + + // Clean rollback: Delete any temporary downloads to prevent corrupted state + try + { + if (Directory.Exists(tempDirPath)) + { + Directory.Delete(tempDirPath, true); + } + } + catch { } + + return false; + } + } + + /// + /// Perform atomic swap using rename-first strategy, allowing replacement of loaded/locked assemblies (like Client.dll). + /// + private void PerformAtomicSwap(string sourcePath, string destPath) + { + string destDir = Path.GetDirectoryName(destPath); + if (!string.IsNullOrEmpty(destDir) && !Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + if (File.Exists(destPath)) + { + // CRITICAL FOR SELF-UPDATE & PROCESS LOCKING: + // Renaming a locked/running file (e.g. Client.dll or Client.exe) is allowed under both Windows and Linux. + // We rename it first to ".patch_old", then atomically swap the new file in place. + string backupPath = destPath + ".patch_old"; + if (File.Exists(backupPath)) + { + try { File.Delete(backupPath); } catch { } + } + + try + { + File.Move(destPath, backupPath); + File.Move(sourcePath, destPath, overwrite: true); + + // Under Linux, inode unlinking allows us to immediately delete the old running binary safely! + // Under Windows, we catch and ignore locking exceptions, leaving the backup to be deleted on next start. + try + { + File.Delete(backupPath); + } + catch (Exception ex) + { + Console.WriteLine($"[Patcher] Running backup file {Path.GetFileName(backupPath)} kept until next application launch: {ex.Message}"); + } + } + catch (Exception ex) + { + Console.WriteLine($"[Patcher] Safe rename-swap failed for {destPath}: {ex.Message}. Attempting direct overwrite."); + File.Move(sourcePath, destPath, overwrite: true); + } + } + else + { + File.Move(sourcePath, destPath, overwrite: true); + } + } + + /// + /// Fetch binary patch list (manifest) file. + /// + private async Task> FetchManifestAsync(CancellationToken cancellationToken) + { + string url = $"{Settings.P_Host}{Settings.P_PatchFileName}"; + + if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + throw new InvalidOperationException($"Invalid patch host URL: {url}"); + } + + using var request = CreateRequest(url); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + byte[] data = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (data == null || data.Length == 0) + { + throw new InvalidDataException("Empty response received from manifest download."); + } + + if (data[0] == 60) // '<' character - got an HTML error page instead of binary list + { + throw new InvalidDataException("Received invalid HTML manifest. Please verify your host patch settings."); + } + + var manifestList = new List(); + using (var stream = new MemoryStream(data)) + using (var reader = new BinaryReader(stream)) + { + int count = reader.ReadInt32(); + for (int i = 0; i < count; i++) + { + manifestList.Add(new FileInformation(reader)); + } + } + + return manifestList; + } + + /// + /// Validate local files against the manifest by checking size and last modification dates. + /// + private List ValidateLocalFiles(List manifestList, out long totalBytesToDownload) + { + var downloadQueue = new List(); + totalBytesToDownload = 0; + + foreach (var manifestInfo in manifestList) + { + string localRelative = NormalizeLocalPath(manifestInfo.FileName); + string localFull = Path.Combine(Settings.P_Client, localRelative); + + var localInfo = GetLocalFileInformation(localFull, manifestInfo.FileName); + + if (localInfo == null || manifestInfo.Length != localInfo.Length || manifestInfo.Creation != localInfo.Creation) + { + downloadQueue.Add(manifestInfo); + totalBytesToDownload += manifestInfo.Length; + } + } + + return downloadQueue; + } + + private FileInformation GetLocalFileInformation(string fullPath, string relativeName) + { + if (!File.Exists(fullPath)) return null; + + var info = new FileInfo(fullPath); + return new FileInformation + { + FileName = relativeName, + Length = (int)info.Length, + Creation = info.LastWriteTime + }; + } + + /// + /// Download a single file and stream-decompress it on-the-fly directly to disk. + /// + private async Task DownloadFileAsync(FileInformation fileInfo, string tempDirPath, CancellationToken cancellationToken) + { + // STRICT CASE-SENSITIVITY MANDATE: + // Preserve manifest path casing perfectly in both the request URL and local file hierarchy. + string serverPath = NormalizeUrlPath(fileInfo.FileName); + string localRelative = NormalizeLocalPath(fileInfo.FileName); + + bool isCompressed = (serverPath != "PList.gz" && (fileInfo.Compressed != fileInfo.Length || fileInfo.Compressed == 0)); + if (isCompressed) + { + serverPath += ".gz"; + } + + string url = $"{Settings.P_Host}{serverPath}"; + string tempFilePath = Path.Combine(tempDirPath, localRelative); + + string tempFileDir = Path.GetDirectoryName(tempFilePath); + if (!string.IsNullOrEmpty(tempFileDir) && !Directory.Exists(tempFileDir)) + { + Directory.CreateDirectory(tempFileDir); + } + + // Resume Function: Check if the file is already fully downloaded in temp directory + if (File.Exists(tempFilePath)) + { + var fi = new FileInfo(tempFilePath); + if (fi.Length == fileInfo.Length) + { + Interlocked.Add(ref _completedBytes, fileInfo.Length); + Interlocked.Increment(ref _filesDownloadedCount); + TriggerProgressUpdate(fileInfo.FileName, $"Resumed {fileInfo.FileName}"); + return; + } + } + + using var request = CreateRequest(url); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) + { + Stream sourceStream = responseStream; + if (isCompressed) + { + // Stream-decompress directly in memory to target stream + sourceStream = new GZipStream(responseStream, CompressionMode.Decompress); + } + + using (sourceStream) + using (var fs = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) + { + // MODERN DECOMPRESSION & NETWORKING MANDATES: + // Decompress on-the-fly directly to target file stream using memory buffer from ArrayPool. + // Absolutely avoids allocating large intermediate byte arrays! + byte[] buffer = ArrayPool.Shared.Rent(8192); + try + { + int bytesRead; + while ((bytesRead = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + await fs.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false); + Interlocked.Add(ref _completedBytes, bytesRead); + TriggerProgressUpdate(fileInfo.FileName, $"Downloading {fileInfo.FileName}..."); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + Interlocked.Increment(ref _filesDownloadedCount); + TriggerProgressUpdate(fileInfo.FileName, $"Finished downloading {fileInfo.FileName}"); + } + + /// + /// Self-update check logic that identifies and handles updates to the executable itself. + /// + public static bool CheckSelfUpdate() + { + string exeDir = AppContext.BaseDirectory; + string fromName = Path.Combine(exeDir, "AutoPatcher.gz"); + string toName = Environment.ProcessPath ?? Path.Combine(exeDir, "AutoPatcher.exe"); + + if (!File.Exists(fromName)) return false; + + Console.WriteLine($"[Self-Update] Found self-update payload: {fromName}"); + + string processName = Path.GetFileNameWithoutExtension(toName); + Process[] processes = Process.GetProcessesByName(processName); + if (processes.Length > 0) + { + foreach (var p in processes) + { + if (p.Id != Environment.ProcessId) + { + try { p.Kill(); } catch { } + } + } + } + + try + { + byte[] rawBytes = File.ReadAllBytes(fromName); + byte[] decompressedBytes = DecompressBytes(rawBytes); + + // Handle process locking by using the safe renaming strategy + if (File.Exists(toName)) + { + string oldBackup = toName + ".patch_old"; + if (File.Exists(oldBackup)) + { + try { File.Delete(oldBackup); } catch { } + } + try + { + File.Move(toName, oldBackup); + } + catch (Exception ex) + { + Console.WriteLine($"[Self-Update] Backup rename failed for {toName}: {ex.Message}. Trying direct delete."); + File.Delete(toName); + } + } + + File.WriteAllBytes(toName, decompressedBytes); + File.Delete(fromName); + + Console.WriteLine($"[Self-Update] Successfully patched and restarting: {toName}"); + + Process.Start(new ProcessStartInfo + { + FileName = toName, + Arguments = "Auto", + UseShellExecute = true + }); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[Self-Update] Error during self-update operation: {ex}"); + return false; + } + } + + public static byte[] DecompressBytes(byte[] gzipData) + { + if (gzipData == null || gzipData.Length < 18) + throw new InvalidDataException("Invalid GZip data."); + + int uncompressedSize = BitConverter.ToInt32(gzipData, gzipData.Length - 4); + if (uncompressedSize < 0) + throw new InvalidDataException("Invalid GZip size."); + + byte[] decompressed = new byte[uncompressedSize]; + using var ms = new MemoryStream(gzipData, writable: false); + using var gzip = new GZipStream(ms, CompressionMode.Decompress); + + int bytesRead; + int offset = 0; + while ((bytesRead = gzip.Read(decompressed, offset, decompressed.Length - offset)) > 0) + { + offset += bytesRead; + } + + return decompressed; + } + + /// + /// Safely clean up obsolete files that are no longer part of the server manifest. + /// + public void CleanUpObsoleteFiles(List manifestList) + { + string clientDir = Settings.P_Client; + if (!Directory.Exists(clientDir)) return; + + string[] filePaths = Directory.GetFiles(clientDir, "*", SearchOption.AllDirectories); + + foreach (var filePath in filePaths) + { + string relativePath = Path.GetRelativePath(clientDir, filePath); + + // Keep Screenshots + if (relativePath.StartsWith("Screenshots", StringComparison.OrdinalIgnoreCase)) + continue; + + // Keep Temp Patch Folder + if (relativePath.StartsWith(".patch_temp", StringComparison.OrdinalIgnoreCase)) + continue; + + string fileName = Path.GetFileName(filePath); + + // Keep system critical client configuration files, C# assemblies, native libs, configs, and executables + string extension = Path.GetExtension(filePath).ToLower(); + if (fileName.Equals("Mir2Config.ini", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Mir2Test.ini", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Mir2Config.ini.patch_old", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Mir2Test.ini.patch_old", StringComparison.OrdinalIgnoreCase) || + fileName.Equals(Path.GetFileName(Environment.ProcessPath), StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith(".patch_old", StringComparison.OrdinalIgnoreCase) || + extension == ".dll" || extension == ".so" || extension == ".pdb" || + extension == ".json" || extension == ".config" || + filePath.Contains(".so.") || + fileName.Equals("Client", StringComparison.OrdinalIgnoreCase)) + continue; + + // Match casing-insensitively for the manifest cleanup validation + bool existsInManifest = false; + foreach (var info in manifestList) + { + string localRelative = NormalizeLocalPath(info.FileName); + if (relativePath.Equals(localRelative, StringComparison.OrdinalIgnoreCase)) + { + existsInManifest = true; + break; + } + } + + if (!existsInManifest) + { + try + { + File.Delete(filePath); + Console.WriteLine($"[Patcher] Cleaned obsolete file: {relativePath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[Patcher] Failed to clean obsolete file {relativePath}: {ex.Message}"); + } + } + } + } + + /// + /// Purge any old backups leftover from previous running assembly replacements. + /// + public static void PurgeOldBackups() + { + try + { + string clientDir = Settings.P_Client; + if (!Directory.Exists(clientDir)) return; + + var files = Directory.GetFiles(clientDir, "*.patch_old", SearchOption.AllDirectories); + foreach (var file in files) + { + try + { + File.Delete(file); + Console.WriteLine($"[Patcher] Purged leftover locked file backup: {Path.GetFileName(file)}"); + } + catch + { + // File might still be locked by running processes + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[Patcher] Failed to purge leftover backups: {ex.Message}"); + } + } + + private HttpRequestMessage CreateRequest(string url) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Version = HttpVersion.Version30; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + + // Add standard browser User-Agent header to bypass Cloudflare/CDN blocks + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); + + if (Settings.P_NeedLogin) + { + string authInfo = $"{Settings.P_Login}:{Settings.P_Password}"; + string base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(authInfo)); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", base64); + } + + return request; + } + + private void TriggerProgressUpdate(string currentFile, string status) + { + double seconds = _downloadStopwatch?.Elapsed.TotalSeconds ?? 0; + double speed = seconds > 0 ? _completedBytes / seconds : 0; + + var args = new PatcherProgressEventArgs + { + CurrentFileName = currentFile, + FilesDownloaded = _filesDownloadedCount, + TotalFiles = _totalFilesToDownload, + BytesDownloaded = _completedBytes, + TotalBytes = _totalBytesToDownload, + SpeedBytesPerSecond = speed, + StatusMessage = status + }; + + ProgressChanged?.Invoke(this, args); + + // Output to console with 500ms throttling to avoid console spam while displaying live progress + lock (_consoleLock) + { + long now = Environment.TickCount64; + if (now - _lastConsoleReportTime >= 500 || _filesDownloadedCount == _totalFilesToDownload) + { + _lastConsoleReportTime = now; + Console.WriteLine($"[Patcher] Status: {status} | Progress: {_filesDownloadedCount}/{_totalFilesToDownload} files ({ConvertBytes(_completedBytes)}/{ConvertBytes(_totalBytesToDownload)}) | Speed: {ConvertBytes((long)speed)}/s"); + } + } + } + + private void LogProgress(string currentFile, int filesDownloaded, int totalFiles, long bytesDownloaded, long totalBytes, double speed, string status) + { + var args = new PatcherProgressEventArgs + { + CurrentFileName = currentFile, + FilesDownloaded = filesDownloaded, + TotalFiles = totalFiles, + BytesDownloaded = bytesDownloaded, + TotalBytes = totalBytes, + SpeedBytesPerSecond = speed, + StatusMessage = status + }; + + ProgressChanged?.Invoke(this, args); + + // Output to console for headless reporting + Console.WriteLine($"[Patcher] Status: {status} | Progress: {filesDownloaded}/{totalFiles} files ({ConvertBytes(bytesDownloaded)}/{ConvertBytes(totalBytes)})"); + } + + private static string ConvertBytes(long byteCount) + { + string[] suffixes = { "B", "KB", "MB", "GB" }; + double count = byteCount; + int index = 0; + while (count >= 1024 && index < suffixes.Length - 1) + { + count /= 1024; + index++; + } + return $"{count:0.##} {suffixes[index]}"; + } + + public static string NormalizeUrlPath(string filePath) + { + return filePath.Replace('\\', '/'); + } + + public static string NormalizeLocalPath(string filePath) + { + return filePath.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + } + } +} diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md new file mode 100644 index 000000000..d9dca2eec --- /dev/null +++ b/Cross-platformPortingExperience.md @@ -0,0 +1,167 @@ +# Legend of Mir Crystal: Linux FNA Porting & Stabilization Experience + +This document compiles the architectural insights, engineering challenges, and technical solutions implemented during the migration and stabilization of the **Legend of Mir Crystal** game client from its legacy Windows GDI+ / SlimDX (DirectX 9) codebase to a modern, cross-platform **FNA (SDL2/Vulkan)** implementation running on Linux (.NET 10). + +--- + +## 1. Executive Summary +The migration of a legacy Direct3D 9 / Windows Forms client to a case-sensitive Linux platform using FNA represents a significant platform paradigm shift. While the compilation layer was resolved early on using target separation (`#if FNA` directives), resolving high-fidelity gameplay rendering, input polling quirks, layout parity, and modernizing GDI+ legacy dependencies took a series of surgical, system-level corrections. + +All major porting regressions—specifically map/ground rendering, blend-state visual artifacts, text overlap in dialogues, focus leaks, mouse-movement tracking, and GDI+ CPU memory/performance bottlenecks—have been fully resolved. By replacing the legacy text pipeline with a hardware-accelerated GPU-resident dynamic TrueType font system (FontStashSharp), the Linux FNA client is now completely stable, dependency-free, and achieves visual and gameplay parity with the legacy Windows client. + +--- + +## 2. Key Challenges & Technical Deep Dive + +### 2.1 File System Case-Sensitivity (VFS Integration) +* **The Problem:** Linux filesystems (e.g., ext4) are case-sensitive, whereas the legacy asset pipeline and map indices were developed on case-insensitive NTFS. Ground tiles and objects like `Tiles.wil` and `SmTiles.wil` failed to resolve, leading to a completely black ground floor. +* **The Solution:** A Virtual File System (VFS) resolution layer (`AssetResolver.cs`) was built. It maps and caches lowercased representations of paths dynamically. We tracked map layers utilizing `Tiles.wil` and unified the lookups so that case mismatches between map indices (`MapCode.cs`) and files on disk are resolved at runtime without requiring an asset-renaming script. + +### 2.2 Reconciling Blend States (Fire, Spells & Lighting) +* **The Problem:** The game library relies heavily on raw `.wil` textures. Under DirectX 9, legacy alpha blending was used directly. When ported to FNA, standard `BlendState.AlphaBlend` expected pre-multiplied alpha textures. Because the client raw textures do not contain pre-multiplied alpha values, fire animations and magic effects rendered with severe black fringes or circular "halos." +* **The Corrections:** + 1. **Standard Blending:** Changed the default `SpriteBatch` pipeline to draw with `BlendState.NonPremultiplied`. This ensures raw `.wil` texture alpha channels are interpolated without darkening background pixels. + 2. **Additive Blending (`SetBlend`):** In legacy SlimDX, `DXManager.SetBlend(true)` changed global DirectX pipeline state, allowing subsequent `Sprite.Draw()` calls to blend additively. FNA's `DXManager.Draw` originally bypassed this entirely, ignoring the `Blending` state and causing spells to render with solid black background boxes. We updated `DXManager.Draw` and `DrawOpaque` to detect the `Blending` state and route draws to `Renderer.DrawBlend()`, restoring native Vulkan/OpenGL additive blending support. + 3. **Radial GPU Lights:** Corrected vertex color interpolation inside `FNARenderer.RenderGPULights`. Edges of lights were interpolating RGB channels to zero (`Color.Transparent`), producing dark gray borders. Using `Color(R, G, B, 0)` for outer vertices preserves color chromaticity while fading to transparent. + +### 2.3 dialogue Layout & Font Measurement Parity +* **The Problem:** Legacy dialog rendering (NPC dialogues, bulletin boards, quest logs, chat links, scrolling labels) relied on GDI+ (`TextRenderer.MeasureText`). Because GDI+ inherently pads bounding boxes, the game's original logic appended extra space characters and subtracted `10` or `11` pixels from measurements to force links/colored text to align. The new `FNATextRenderer` uses a pixel-perfect `SpriteFont` measurement which has zero padding. Applying these GDI+ "hacks" shifted colored/interactive text fragments to the left, causing them to overlay and overlap regular text. +* **The Solution:** We systematically searched the UI codebase and introduced conditional compilation `#if FNA` in `NPCDialogs.cs`, `QuestDialogs.cs`, `NoticeDialog.cs`, `MainDialogs.cs`, and `MirScrollingLabel.cs`. For FNA builds, we stripped out the trailing space suffix and the hardcoded pixel location offsets (e.g. `-10` or `-11`). The dialogue fragments now align naturally on their exact baselines without layout drift. + +### 2.4 Keyboard Shortcuts & Focus Leaks +* **The Problem:** Typing in the chat box would trigger global keyboard shortcuts (e.g., closing windows, toggling UI states). +* **The Solution:** We updated `MirTextBox.cs`'s focus synchronization. In the FNA pipeline, global keyboard polling bypasses classic WinForms focus chains. By explicitly calling `Activate()` and `Deactivate()` inside the textbox's `SetFocus()` and `LoseFocus()` overrides, the UI manager correctly knows when keyboard inputs must be consumed by the focused chat controls rather than bubbling up to the game scene. + +### 2.5 Infinite Mouse Movement & Stuck Buttons +* **The Problem:** Upon entering the game, the player character would run continuously towards the mouse pointer. Left clicks were ignored, and right clicks stopped the movement but left clicks on the floor wouldn't resume regular walk cycles. +* **The Solution:** Under Windows, global mouse tracking was hooked into the host `Form` via `CMain_MouseUp`, which cleared the game's static tracking state (`MapControl.MapButtons &= ~e.Button`). Because the FNA build operates headlessly without the `CMain` form window events, `MapButtons` was never cleared on mouse release. Once clicked, a mouse button state stayed `Pressed` indefinitely. +* **The Solution:** We directly registered a `MouseUp` handler in `MapControl` inside `GameScene.cs`: + ```csharp + private static void OnMouseUp(object sender, MouseEventArgs e) + { + MapButtons &= ~e.Button; + if (e.Button != MouseButtons.Right || !Settings.NewMove) + GameScene.CanRun = false; + } + ``` + This immediately intercept releasing clicks on the map surface, restoring fully responsive, click-to-move, and click-and-hold running mechanics. + +### 2.6 TrueType Font Point-to-Pixel Scaling +* **The Problem:** All text in the game appeared extremely small compared to the legacy Windows client. +* **The Solution:** We identified that `System.Drawing.Font` sizes are defined in Points (1/72 inch), whereas FNA's `FontStashSharp` renderer expects sizes in Pixels. At standard 96 DPI, 1 Point is ~1.33 Pixels, which meant all fonts were rendering at ~75% of their intended size. We updated `FNAFontManager.GetFont()` to scale the size parameter by `96f / 72f` before fetching the font from the cache. Furthermore, we modified `MirTextBox` to dynamically calculate the vertical Y-offset of the text label based on its parent control's height, preventing clipping on smaller input fields (like the 13px chat box). + +### 2.7 Headless Server Path Normalization (\`\\\` vs \`/\` in Data Files) +* **The Problem:** The server database was designed on Windows, referencing scripts, drop lists, and scripted load/save paths using Windows-style backslashes (`\`). On Linux, standard library paths combined via `Path.Combine` treated `\` as a literal character in the file name instead of a path separator. This led to errors like `Script Not Found: GuildTerritory\GA0\GTStore-GA0` and failures to locate drop files and persistent player variables. +* **The Solution:** We modified the server path resolution layer to dynamically normalize backslashes using `.Replace('\\', Path.DirectorySeparatorChar)`. This change was systematically applied to: + 1. **NPC Scripts:** In `NPCScript.cs`, before combining paths to load scripts. + 2. **Drop Files & #INSERT Directives:** In `Envir.cs` and `MonsterInfo.cs`, before loading monster-specific drop tables and resolving drop includes. + 3. **Script Commands (`LOADVALUE`, `SAVEVALUE`, `DROP`):** In `NPCSegment.cs`, ensuring path arguments resolved within custom script commands are cross-platform compatible. + +### 2.8 Server Drop Syntax Verification +* **The Problem:** The parser for drop configuration files (e.g., `Envir/Drops/HiGreatGhoul.txt`) expected a strict space delimiter between the drop rate fraction and the item identifier (e.g., `1/1 RedDagger Q`). Typing anomalies in data files, such as `1/1RedDagger Q`, failed to parse and caused silent failures when compiling the server drops list. +* **The Solution:** We corrected the malformed drop directives in the dataset and established clear guidelines regarding syntax spacing limits for drops. + +### 2.9 Startup Type Load Crash +* **The Problem:** When running under FNA/Linux with target framework `.NET 10.0`, the client crashed immediately at startup with a `TypeLoadException` regarding the type `Microsoft.Xna.Framework.Graphics.GraphicsResource`. +* **The Solution:** We added a `TypeForwardedTo` attribute for `GraphicsResource` to `TypeForwarders.cs` in the `MonoGameCompat` project to resolve type resolution between assemblies under the new runtime environment. + +### 2.10 Case-Sensitive Audio Files Lookup +* **The Problem:** Sound effects failed to play under case-sensitive Linux filesystems. The audio module was searching for audio files by appending pre-existing `.wav` extensions when searching the system directory paths, causing file-system lookup failures. +* **The Solution:** We patched `SoundManager.cs`'s `LoadSoundEffect` to normalize paths and detect pre-existing extensions cleanly, maintaining cross-platform compatibility. + +### 2.11 Texture Decompression and Dynamic GPU Uploading +* **The Problem:** In FNA mode, the game assets (decompressed from LZO format) were never loaded into the GPU. A missing `#else` branch in `#if !FNA` inside `MLibrary.CreateTexture()` left the `Image` texture object completely uninitialized, rendering the active scene entirely black. +* **The Solution:** We implemented the FNA branch to allocate a `Texture2D` instance on the active `GraphicsDevice` and upload the decompressed color channels dynamically to the GPU memory (swapping the color channels to match XNA's RGBA format). + +### 2.12 Unmanaged Memory Segmentation Fault +* **The Problem:** The transparency and mouse-transparency checks of the client controls are performed by inspecting the raw decompressed image bytes directly in memory. Under DirectX, this was done via direct pointer access, but in FNA, referencing this pointer resulted in a segfault because the buffer was not pinned. +* **The Solution:** We modified `MLibrary.cs` to dynamically marshal and copy the raw decompressed bytes to a globally allocated unmanaged memory pointer (`Data`) during texture creation, and cleanly free it when the image GC finalizes. + +### 2.13 Graphics Device Initialization Race Condition (The Black Screen Bug) +* **The Problem:** Even with valid GPU texture data and correct diagnostics, the client screen remained pitch black on startup. We discovered that `FNAEntry.Initialize()` called `base.Initialize()`, which immediately triggered the virtual `LoadContent()` method. Since `DXManager.Renderer = Renderer;` was called in `LoadContent()` but `Renderer = new FNARenderer(...)` was instantiated *after* `base.Initialize()`, the renderer field was still null at allocation time, leaving `DXManager.Renderer` permanently null and causing all high-level rendering calls to return early. +* **The Solution:** We corrected the execution order by initializing `Renderer` and assigning `DXManager.Renderer = Renderer;` immediately before executing the base startup routine. + +### 2.14 Headless Auto-Patcher Refactoring & Stabilization +* **The Problem:** The game client's updater logic was tightly integrated with Windows Forms, preventing the game from launching or updating on headless Linux platforms. Porting it also introduced critical cross-platform edge cases: + 1. **Strict Case-Sensitivity on Linux CDN Mirrors:** A case-sensitivity mismatch between the filenames parsed from the binary manifest (`PList.gz`) and the requests made to the CDN (e.g. `Data/Monster/001.Pak` vs. URL casing) resulted in HTTP 404 errors. + 2. **File Path Separator Mismatches:** On Linux filesystems, relative paths starting with `.\` (e.g., `new InIReader(@".\Mir2Config.ini")`) were parsed literally as starting with a dot and a backslash character, failing to locate the configuration files. + 3. **Process Locking & Atomic Swapping:** Overwriting active assemblies (like `Client.dll` or `AutoPatcher.exe`) would trigger write-access violations while the application process was running. + 4. **Synchronization Context Deadlocks:** Blocking synchronous entry-point calls (`.GetResult()`) on async patch tasks without `.ConfigureAwait(false)` caused thread-scheduling deadlocks, halting the application early with `Progress: 0/0 files`. +* **The Solution:** + 1. **Decoupled Patcher Engine:** Extracted WinForms UI code and built `HeadlessPatcher.cs` using high-performance, zero-allocation GZip streaming (`ArrayPool.Shared`). + 2. **Separator & File Normalization:** Configured cross-platform relative path lookups using `AppContext.BaseDirectory` combined with forward slashes for CDN request URLs. + 3. **Atomic Swapping & Inode Unlinking:** Implemented a rename-first assembly replacement strategy (moving locked binaries to `.patch_old`). On Linux, inode unlinking allowed immediate deletion, while Windows cleanups were deferred to next startup. + 4. **Deadlock Resolution & Throttled Console:** Applied `.ConfigureAwait(false)` to all async await calls to bypass calling synchronization contexts. Integrated thread-safe 500ms throttled console reporting to prevent terminal spam while displaying live download progress. + 5. **Auto-Resume Capabilities:** Updated the download pipeline to skip files that already exist in `.patch_temp` with verified sizes, enabling automatic download resumption upon client restarts. + +### 2.15 Nested Project Directory Globbing & TargetFrameworkAttribute Duplication +* **The Problem:** When running the compilation command `dotnet build Client/Client.csproj -f net10.0 -c Release`, the build failed with `error CS0579: Duplicate 'global::System.Runtime.Versioning.TargetFrameworkAttribute' attribute`. This occurred because the `Platform/MonoGameCompat` project is nested under the `Client` directory structure. By default, MSBuild globbing (`**/*.cs`) compiled the source files and dynamic `obj/` assembly attributes of the nested project directly into the parent `Client` assembly, while also referencing it as a separate project reference, causing duplicate compilation and attribute declarations. +* **The Solution:** Added an explicit `` directive inside `Client.csproj` under the source file partitioning block. This prevents MSBuild from recursively globbing nested subproject files, ensuring clean separation of compile-time units while maintaining reference integrity. + +### 2.16 Zero-GDI+ Modernization & GPU-Resident Text Rendering Pipeline +* **The Problem:** Microsoft deprecated Unix support in `System.Drawing.Common` (GDI+) because the underlying library (`libgdiplus`) causes severe memory leaks, deadlocks, and missing glyph crashes on modern Linux. Initially, a Unix compatibility switch was introduced to temporarily allow GDI+, but this was a critical architectural regression. Text measurement and rasterization in the game client's labels and dialogue systems were tightly coupled to CPU-bound `Bitmap` locking, pixel array copying, and custom ARGB-to-RGBA conversions before uploading to GPU memory. +* **The Solution:** + 1. **Purged GDI+ Dependencies:** Deleted the Unix compatibility switch from `ProgramFNA.cs` and purged the `System.Drawing.Common` package dependency from the `net10.0` target in `Client.csproj`. + 2. **Type Compatibility Stubs:** Replaced GDI+ classes like `System.Drawing.Font`, `System.Drawing.FontStyle`, `System.Drawing.Bitmap`, and others with clean C# stubs in `MirInputTypes.cs`. We redirected all layout calculations and string measurements to `FNAFontManager` to run on the CPU using TrueType metrics without GDI+. + 3. **MonoGame Compatibility Layer:** Standard NuGet packages like `FontStashSharp.MonoGame` expect dependencies on `MonoGame.Framework.dll`. To avoid namespace and assembly conflicts under FNA, we created a lightweight compatibility library called `MonoGame.Framework.dll` containing `.NET` `TypeForwardedTo` attributes. This dynamically binds all MonoGame types directly to `FNA.dll` at runtime. + 4. **GPU-Resident Rendering:** Refactored `MirLabel.CreateTexture()` to instantiate a GPU-resident `RenderTarget2D` in VRAM. It clears the render target and renders outlines and text on the fly using FNA's native `SpriteBatch` combined with `FontStashSharp`'s `DrawString` extension, running 100% on the GPU with zero CPU memory allocations. + +### 2.17 Comprehensive Relative Backslash Path Normalization +* **The Problem:** Windows-style relative backslash paths (e.g. `@".\Localization\"`, `@".\KeyBinds.ini"`, and `@".\Error.txt"`) were hardcoded across several modules including Client Settings, Server Settings, KeyBindings, AutoPatcher, and tools. On Linux, these paths were treated as literal directory/file names with backslashes instead of resolving relative to the application base directory, resulting in unwanted file creation and path mismatches. +* **The Solution:** We systematically replaced all instances of hardcoded relative backslash paths with cross-platform paths using `Path.Combine` and `AppContext.BaseDirectory` or `AppDomain.CurrentDomain.BaseDirectory`. This ensures that local configuration, localization files, and error logs are stored relative to the executing assembly's path, regardless of operating system differences. + +### 2.18 CJK & Chinese Font Localization Fallback System +* **The Problem:** When configuring the game client with `Language=Chinese` in `Mir2Config.ini`, the game loaded Chinese translation files (`Chinese.json`), but failed to render any text. The FNA font engine (`FNAFontManager` built on `FontStashSharp`) only initialized standard system Latin TrueType fonts. Because these fonts lacked CJK character glyphs, the game drawn empty text. +* **The Solution:** We modified `FNAFontManager.cs` to locate available CJK/Chinese system fonts on Linux (such as `DroidSansFallbackFull.ttf` and `Fandol` fonts) and register them as fallback fonts in the same `FontSystem` instance via `FontSystem.AddFont()`. The library automatically resolves missing glyphs by checking sequentially added fallback fonts. We wrapped the registration loop in a try-catch block to gracefully skip unsupported formats (such as variable/TTC font collections that fail to initialize in the underlying `stb_truetype` engine) without crashing the client startup. + +### 2.19 MirLabel Alignment & DrawFormat Support +* **The Problem:** Under GDI+ (Windows), standard `TextRenderer.DrawText` honors formatting flags like horizontal and vertical center inside a control's bounding box. In the FNA engine port (Linux), `MirLabel.DrawControl()` drew the text string directly at the control's top-left coordinates `DisplayLocation.X, DisplayLocation.Y` without inspecting `DrawFormat`. This caused centered elements, such as the player's name text and guild name in the character status/equipment dialog box, to render left-aligned/off-left. +* **The Solution:** We updated `MirLabel.cs` (`DrawControl`) under FNA to calculate the measured size using `font.MeasureString(Text)` and shift `pos` by the appropriate horizontal and vertical offsets when center/right/bottom alignment flags are present in `DrawFormat`. We also added standard `Top` and `Bottom` flags to the simulated `TextFormatFlags` enum in `MirInputTypes.cs` to maintain compilation parity. + +### 2.20 Unified Background & Border Rendering +* **The Problem:** Solid background color boxes and borders (used in tooltips, character creation screens, and info panels) failed to render in the FNA client, leaving text floating without a readable background. Under legacy SlimDX, they were drawn using DirectX device state APIs inside the `#if !FNA` block. +* **The Solution:** We introduced a unified rendering abstraction by adding a `DrawRectangle` signature to `IGraphicsRenderer.cs` and implementing it in `FNARenderer.cs` using a 1x1 pixel white texture dynamically colored and stretched via `SpriteBatch.Draw`. We then updated `MirControl.DrawControl` and `DrawBorder` under FNA to route their background and border drawing operations through this new API. + +### 2.21 Height-Constrained Word Wrapping +* **The Problem:** NPC dialogues and Notice panels displayed overlapping text and misaligned clickable links under FNA. Single-line labels (such as dialogue rows) are instantiated with a height of 20px but carried default `WordBreak` flags. Under FNA, `SpriteBatch.DrawString()` does not clip rendering to the label's height. Consequently, long dialogue lines wrapped to a second line (drawing over lines below them) while their companion colored clickable links (positioned at unwrapped coordinates) stayed on the first line, overlapping. +* **The Solution:** We refactored `MirLabel.cs` (`CreateTexture()`) and `FNATextRenderer.cs` (`MeasureText()`) to only wrap text when the control's height has enough vertical space to display at least two lines (`Size.Height >= singleLineHeight * 1.5f`). If the height constraint is not met, wrapping is disabled and the text draws on a single line, restoring pixel-perfect alignment with clickable button overlays. + +### 2.22 Client Input Control, Focus Traversal & Caret Resolution +* **The Problem:** The FNA text box implementation suffered from several cross-platform input regressions: + 1. Hovering and clicking buttons was delayed or locked when a textbox had focus, requiring clicking outside the textbox first. + 2. The chat input box remained invisible/inactive, rendering the background white and ignoring clicks. Once activated via Enter or chat content, it became gray and interactive, but clicking on it the first time was blocked. Also, clicking the leftmost boundary focused it, but normal interaction should allow clicking anywhere inside. + 3. The cursor caret in FNA was rendered as a thick, outlined block and was offset to the right by about one character due to font cell bearing differences. + 4. The `Tab` and `Shift+Tab` keys did not traverse focus between textboxes. + 5. The guild name creation input box had a hardcoded ASCII alphanumeric filter, blocking Chinese and CJK character input. +* **The Solution:** + 1. **Button Interaction:** Modified `MirControl.Highlight()` to allow hover highlights on buttons when `ActiveControl` is a `MirTextBox`, eliminating the click delay. + 2. **IsMouseOver Occlusion & First-Click Activation:** Updated `MirImageControl.IsMouseOver` to recursively check visible child controls first, preventing transparent parent textures from blocking clicks. Registered a `MouseDown` handler on `ChatDialog` that captures clicks inside `ChatTextBox.DisplayRectangle` when it is invisible, toggling it visible and focusing it immediately. + 3. **Caret Alignment & Styling:** Set `OutLine = false` on `_caretLabel` inside `MirTextBox.cs`'s FNA constructor to render a thin line cursor. Changed the caret positioning offset in `UpdateLabel()` from `_textLabel.Size.Width - 3` to `_textLabel.Size.Width - 5` to align the cursor flush with the text. + 4. **Keyboard Event Routing & Tab Focus:** Patched keyboard polling in `FNAEntry.PollKeyboard()` to capture modifiers and route keypresses directly to the active `MirTextBox`. Implemented focus cycling in `MirTextBox.OnKeyDown` for `Tab` and `Shift+Tab` by recursively searching the active scene's control tree for eligible textboxes. + 5. **CJK Character Support in Guilds:** Replaced the ASCII check on the guild creation inputBox in `GameScene.cs` with `char.IsLetterOrDigit` to natively support Chinese/CJK letters. + +### 2.23 Non-AutoSize Label Clipping & Truncation +* **The Problem:** In FNA builds, `SpriteBatch.DrawString` does not constrain rendering to the bounding box of a `MirLabel`. When `AutoSize` was set to `false` (e.g. for mail message previews or restricted name fields), long strings floated outside the label's `Size.Width` bounds, leaking text into neighboring controls and outside the dialog. Under GDI+, these strings were naturally clipped at pixel-level boundaries. +* **The Solution:** We updated `MirLabel.CreateTexture()` for FNA to check if `AutoSize` is `false`. When boundaries are constrained, a binary search function `TruncateText()` performs character-level truncation using `font.MeasureString` to fit the string to the label's `Width`. Additionally, the label now performs vertical line clipping to discard text lines exceeding `Size.Height`, preventing text from overflowing vertically. + +### 2.24 Font-Independent Dialog Layout Grid Aligner +* **The Problem:** The Stats page in the Guild dialog box used a single multi-line `StatusHeaders` label with double newlines (`\n\n`) to lay out headers ("Guild Name", "Level", "Members"). In Chinese settings, the CJK fallback font (e.g., Fandol) has different vertical metrics and line spacing compared to standard Latin fonts. This caused the "Level" and "Members" headers to shift upwards by half a character and a full character height respectively, while their corresponding value labels (which are separate controls positioned at fixed Y offsets) remained static, leading to severe layout misalignment. +* **The Solution:** We replaced the single multi-line header control in `GuildDialog.cs` with three distinct header labels: `StatusGuildNameHeader`, `StatusLevelHeader`, and `StatusMembersHeader`. We parse the localized text mapping, split it on newline tokens, and instantiate the labels at the exact fixed Y coordinates (`47`, `73`, and `99`) matching the values. This decoupled the UI grid alignment from font-specific line height measurements. + +### 2.25 Swapchain Backbuffer Content Discard (The Black Screen on Render Target Switches) +* **The Problem:** When implementing cave and nighttime lighting effects, switching between render targets (drawing the light mask onto a custom `LightRenderTarget` and then switching back to the default backbuffer to multiply blend) caused the entire game screen to turn pitch black under multiplicative blending. Switching to opaque blending proved the light geometries were drawn correctly, but the previously rendered game scene was completely lost. +* **The Solution:** In FNA/XNA, the default backbuffer presentation parameters initialize with `RenderTargetUsage = RenderTargetUsage.DiscardContents`. Under Vulkan or modern OpenGL graphics drivers, changing the active render target ends the render pass on the default swapchain backbuffer. When switching back via `SetRenderTarget(null)` to start a new render pass, Vulkan uses a discard load operation (`VK_ATTACHMENT_LOAD_OP_DONT_CARE` or `VK_ATTACHMENT_LOAD_OP_CLEAR`), erasing the previously rendered game scene. We fixed this by subscribing to the `PreparingDeviceSettings` event on `GraphicsDeviceManager` and explicitly setting `e.GraphicsDeviceInformation.PresentationParameters.RenderTargetUsage = RenderTargetUsage.PreserveContents` during initialization and resolution/viewport changes. This forces the Vulkan driver to load and preserve the swapchain image contents across render target changes. + +--- + +## 3. Structural Porting Guidelines for Future Reference +For engineers maintaining this cross-platform codebase, follow these rules to maintain platform parity: +1. **Never Bypass Global Event Wrappers:** Do not read raw input state dynamically in scene logic without updating global stubs. Input events must flow from `FNAEntry.cs` into the UI control hierarchy cleanly. +2. **Keep Rendering Code Separated:** Keep the platform-specific graphics backends confined to `Platform/FNA` and `SlimDX` layers. Ensure `DXManager` remains the uniform interface. +3. **Use Idempotent Math for Layouts:** Avoid hardcoded pixel offsets that attempt to patch font engine limitations. If font scaling or kerning differs, handle it at the `FNATextRenderer` level, not inside dialogue line-parsers. +4. **Enforce Task Disconnection in Synchronous Paths:** When calling async logic from synchronous entry points, always utilize `.ConfigureAwait(false)` to prevent thread-pool and game-loop deadlocks. + +--- + +## 4. Final Verdict +The Legend of Mir Crystal client is now **Linux native and stable**, using modern Vulkan/Vulkan-on-Mesa rendering. Visual layouts are crisp, mouse and keyboard inputs are precise, and graphics blending operates exactly as intended by the game's original art system. The automatic updater is fully headless, supporting case-sensitive Unix mirrors, process swapping, and resilient auto-resumable patching. diff --git a/Legend of Mir.sln b/Legend of Mir.sln index ceaccdff1..d1a02c418 100644 --- a/Legend of Mir.sln +++ b/Legend of Mir.sln @@ -52,48 +52,140 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibraryEditor", "LibraryEdi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibraryViewer", "LibraryViewer\LibraryViewer.csproj", "{456E4777-5510-49E1-BE8D-C536753964E3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.Headless", "Server.Headless\Server.Headless.csproj", "{0C7A7648-151F-461E-89BD-6D83E0B38454}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{DAD03CFC-D5FA-AC94-41C7-9ABAB326F09E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|Any CPU.Build.0 = Debug|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|x64.ActiveCfg = Debug|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|x64.Build.0 = Debug|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|x86.ActiveCfg = Debug|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Debug|x86.Build.0 = Debug|Any CPU {520C78C8-1838-4A22-B024-08C044ADE635}.Release|Any CPU.ActiveCfg = Release|Any CPU {520C78C8-1838-4A22-B024-08C044ADE635}.Release|Any CPU.Build.0 = Release|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Release|x64.ActiveCfg = Release|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Release|x64.Build.0 = Release|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Release|x86.ActiveCfg = Release|Any CPU + {520C78C8-1838-4A22-B024-08C044ADE635}.Release|x86.Build.0 = Release|Any CPU {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|x64.ActiveCfg = Debug|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|x64.Build.0 = Debug|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Debug|x86.Build.0 = Debug|Any CPU {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|Any CPU.ActiveCfg = Release|Any CPU {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|Any CPU.Build.0 = Release|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|x64.ActiveCfg = Release|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|x64.Build.0 = Release|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|x86.ActiveCfg = Release|Any CPU + {ABB9A7A2-DB8C-497F-B67E-C522F812117E}.Release|x86.Build.0 = Release|Any CPU {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|x64.Build.0 = Debug|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Debug|x86.Build.0 = Debug|Any CPU {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|Any CPU.ActiveCfg = Release|Any CPU {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|Any CPU.Build.0 = Release|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|x64.ActiveCfg = Release|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|x64.Build.0 = Release|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|x86.ActiveCfg = Release|Any CPU + {5B5CAC11-D4DE-44EC-9175-0D2BB7A9603C}.Release|x86.Build.0 = Release|Any CPU {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|x64.ActiveCfg = Debug|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|x64.Build.0 = Debug|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Debug|x86.Build.0 = Debug|Any CPU {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|Any CPU.Build.0 = Release|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|x64.ActiveCfg = Release|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|x64.Build.0 = Release|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|x86.ActiveCfg = Release|Any CPU + {884D7004-37B4-4C38-AA64-F73BF64E9CB4}.Release|x86.Build.0 = Release|Any CPU {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|x64.Build.0 = Debug|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Debug|x86.Build.0 = Debug|Any CPU {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|Any CPU.ActiveCfg = Debug|Any CPU {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|Any CPU.Build.0 = Debug|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|x64.ActiveCfg = Release|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|x64.Build.0 = Release|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|x86.ActiveCfg = Release|Any CPU + {C1F2FAE3-485C-4BF4-8939-B44EAD5619D4}.Release|x86.Build.0 = Release|Any CPU {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|x64.ActiveCfg = Debug|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|x64.Build.0 = Debug|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|x86.ActiveCfg = Debug|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Debug|x86.Build.0 = Debug|Any CPU {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|Any CPU.ActiveCfg = Release|Any CPU {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|Any CPU.Build.0 = Release|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|x64.ActiveCfg = Release|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|x64.Build.0 = Release|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|x86.ActiveCfg = Release|Any CPU + {6312929E-6CD5-48FE-A147-C10C09983A00}.Release|x86.Build.0 = Release|Any CPU {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|Any CPU.Build.0 = Debug|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|x64.ActiveCfg = Debug|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|x64.Build.0 = Debug|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|x86.ActiveCfg = Debug|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Debug|x86.Build.0 = Debug|Any CPU {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|Any CPU.ActiveCfg = Release|Any CPU {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|Any CPU.Build.0 = Release|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|x64.ActiveCfg = Release|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|x64.Build.0 = Release|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|x86.ActiveCfg = Release|Any CPU + {297E115E-AC0A-48CC-8FD7-059FED59BA73}.Release|x86.Build.0 = Release|Any CPU {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|x64.ActiveCfg = Debug|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|x64.Build.0 = Debug|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|x86.ActiveCfg = Debug|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Debug|x86.Build.0 = Debug|Any CPU {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|Any CPU.Build.0 = Release|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|x64.ActiveCfg = Release|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|x64.Build.0 = Release|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|x86.ActiveCfg = Release|Any CPU + {A3B8CA9F-5C91-49CB-902D-62A38DF4EF93}.Release|x86.Build.0 = Release|Any CPU {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|x64.ActiveCfg = Debug|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|x64.Build.0 = Debug|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Debug|x86.Build.0 = Debug|Any CPU {456E4777-5510-49E1-BE8D-C536753964E3}.Release|Any CPU.ActiveCfg = Release|Any CPU {456E4777-5510-49E1-BE8D-C536753964E3}.Release|Any CPU.Build.0 = Release|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Release|x64.ActiveCfg = Release|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Release|x64.Build.0 = Release|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Release|x86.ActiveCfg = Release|Any CPU + {456E4777-5510-49E1-BE8D-C536753964E3}.Release|x86.Build.0 = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|x64.ActiveCfg = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|x64.Build.0 = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|x86.ActiveCfg = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Debug|x86.Build.0 = Debug|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|Any CPU.Build.0 = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|x64.ActiveCfg = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|x64.Build.0 = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|x86.ActiveCfg = Release|Any CPU + {0C7A7648-151F-461E-89BD-6D83E0B38454}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Server.Headless/CommandProcessor.cs b/Server.Headless/CommandProcessor.cs new file mode 100644 index 000000000..3b48cab19 --- /dev/null +++ b/Server.Headless/CommandProcessor.cs @@ -0,0 +1,1341 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Server; +using Server.MirEnvir; +using Server.MirDatabase; +using Server.MirObjects; + +namespace Server.Headless +{ + public class CommandProcessor + { + private readonly ConsoleServerHost _host; + private readonly CancellationTokenSource _cts; + private readonly List _history = new List(); + private int _historyIndex = 0; + private string _tempInput = ""; + private DateTime _lastTabTime = DateTime.MinValue; + + private static readonly string[] PrimaryCommands = new[] + { + "help", "status", "start", "stop", "reboot", "restart", "exit", "quit", + "reload", "say", "broadcast", "list", "kick", "blockedips", "player", "ipban", "ipunban", "gm" + }; + + private static readonly string[] ReloadSubcommands = new[] { "npc", "drops", "line", "all" }; + private static readonly string[] ListSubcommands = new[] { "players", "guilds" }; + private static readonly string[] BlockedIpsSubcommands = new[] { "list", "clear", "add", "remove" }; + private static readonly string[] PlayerSubcommands = new[] + { + "status", "info", "edit", "message", "kick", "kill", "killpets", "safezone", + "chatban", "chatunban", "ban", "unban", "flag" + }; + private static readonly string[] PlayerEditStats = new[] { "level", "gold", "credit", "pk" }; + + public static readonly object ConsoleLock = new object(); + public static string CurrentInput { get; set; } = ""; + public static int CursorPosition { get; set; } = 0; + public static bool IsReadingInput { get; set; } = false; + + public static void ClearInputLine() + { + lock (ConsoleLock) + { + if (!IsReadingInput) return; + int length = 2 + (CurrentInput?.Length ?? 0); + Console.Write("\r" + new string(' ', length) + "\r"); + } + } + + public static void RedrawInputLine() + { + lock (ConsoleLock) + { + if (!IsReadingInput) return; + Console.Write("> " + CurrentInput); + int backspaces = (CurrentInput?.Length ?? 0) - CursorPosition; + for (int i = 0; i < backspaces; i++) + { + Console.Write("\b"); + } + } + } + + private void Print(string msg) + { + lock (ConsoleLock) + { + Console.WriteLine(msg); + } + } + + private void PrintTiledCompletions(List options) + { + lock (ConsoleLock) + { + Console.WriteLine(); + const int colWidth = 16; + int windowWidth = 80; + try { windowWidth = Console.WindowWidth; } catch {} + int lineLen = 0; + foreach (var option in options) + { + if (lineLen > 0 && lineLen + colWidth > windowWidth) + { + Console.WriteLine(); + lineLen = 0; + } + Console.Write(option.PadRight(colWidth)); + lineLen += colWidth; + } + Console.WriteLine(); + Console.Write("> " + CurrentInput); + int backspaces = CurrentInput.Length - CursorPosition; + for (int i = 0; i < backspaces; i++) + { + Console.Write("\b"); + } + } + } + + public CommandProcessor(ConsoleServerHost host, CancellationTokenSource cts) + { + _host = host; + _cts = cts; + } + + public async Task RunAsync() + { + _host.Log("Headless CLI Interactive Console Initialized."); + _host.Log("Type 'help' or '?' for a list of available commands. Tab completion is active."); + + while (!_cts.Token.IsCancellationRequested) + { + string line = null; + try + { + bool isInteractive = !Console.IsInputRedirected && !Console.IsOutputRedirected; + if (isInteractive) + { + lock (ConsoleLock) + { + Console.CursorVisible = true; + IsReadingInput = true; + CurrentInput = ""; + CursorPosition = 0; + Console.Write("> "); + } + } + + line = await ReadLineWithTabCompletionAsync(_cts.Token); + } + catch (TaskCanceledException) + { + break; + } + catch (Exception ex) + { + lock (ConsoleLock) + { + IsReadingInput = false; + } + _host.Log($"Console read error: {ex.Message}"); + await Task.Delay(1000, _cts.Token); + continue; + } + + lock (ConsoleLock) + { + IsReadingInput = false; + } + + if (line == null) + { + // EOF or TTY not attached, yield to prevent high CPU utilization + await Task.Delay(2000, _cts.Token); + continue; + } + + line = line.Trim(); + if (string.IsNullOrEmpty(line)) + continue; + + if (line.StartsWith("/")) + { + line = line.Substring(1); + } + + try + { + ExecuteCommand(line); + } + catch (Exception ex) + { + Print($"Error executing command '{line}': {ex.Message}"); + } + } + } + + private async Task ReadLineWithTabCompletionAsync(CancellationToken token) + { + if (Console.IsInputRedirected || Console.IsOutputRedirected) + { + return await Task.Run(() => Console.ReadLine(), token); + } + + var buffer = new StringBuilder(); + int cursor = 0; + int activePrefixLength = 0; + string textBeforeTab = null; + + while (!token.IsCancellationRequested) + { + if (!Console.KeyAvailable) + { + await Task.Delay(25, token); + continue; + } + + var keyInfo = Console.ReadKey(true); + + if (keyInfo.Key == ConsoleKey.Enter) + { + lock (ConsoleLock) + { + Console.WriteLine(); + } + string cmd = buffer.ToString(); + if (!string.IsNullOrEmpty(cmd)) + { + if (_history.Count == 0 || _history[_history.Count - 1] != cmd) + { + _history.Add(cmd); + } + } + _historyIndex = _history.Count; + return cmd; + } + else if (keyInfo.Key == ConsoleKey.Backspace) + { + if (cursor > 0) + { + lock (ConsoleLock) + { + cursor--; + buffer.Remove(cursor, 1); + CurrentInput = buffer.ToString(); + CursorPosition = cursor; + + Console.Write("\b \b"); + if (cursor < buffer.Length) + { + string remaining = buffer.ToString().Substring(cursor); + Console.Write(remaining + " "); + for (int i = 0; i <= remaining.Length; i++) + { + Console.Write("\b"); + } + } + } + } + } + else if (keyInfo.Key == ConsoleKey.Tab) + { + DateTime now = DateTime.UtcNow; + bool isDoubleTab = (now - _lastTabTime).TotalMilliseconds <= 500; + if (isDoubleTab) + { + _lastTabTime = DateTime.MinValue; + } + else + { + _lastTabTime = now; + } + + textBeforeTab = buffer.ToString(); + var completions = GetCompletions(textBeforeTab, out activePrefixLength); + + if (completions.Count == 1) + { + lock (ConsoleLock) + { + string completion = completions[0]; + int currentLength = buffer.Length; + for (int i = 0; i < currentLength; i++) + { + Console.Write("\b \b"); + } + + buffer.Clear(); + string prefix = textBeforeTab.Substring(0, textBeforeTab.Length - activePrefixLength); + buffer.Append(prefix); + buffer.Append(completion); + cursor = buffer.Length; + + CurrentInput = buffer.ToString(); + CursorPosition = cursor; + + Console.Write(buffer.ToString()); + } + } + else if (completions.Count > 1) + { + if (isDoubleTab) + { + PrintTiledCompletions(completions); + } + else + { + Console.Beep(); + } + } + } + else if (keyInfo.Key == ConsoleKey.Escape) + { + lock (ConsoleLock) + { + int currentLength = buffer.Length; + for (int i = 0; i < currentLength; i++) + { + Console.Write("\b \b"); + } + buffer.Clear(); + cursor = 0; + CurrentInput = ""; + CursorPosition = 0; + } + } + else if (keyInfo.Key == ConsoleKey.UpArrow) + { + if (_history.Count > 0) + { + if (_historyIndex == _history.Count) + { + _tempInput = buffer.ToString(); + } + + if (_historyIndex > 0) + { + _historyIndex--; + lock (ConsoleLock) + { + int currentLength = buffer.Length; + for (int i = 0; i < currentLength; i++) + { + Console.Write("\b \b"); + } + + buffer.Clear(); + buffer.Append(_history[_historyIndex]); + cursor = buffer.Length; + + CurrentInput = buffer.ToString(); + CursorPosition = cursor; + + Console.Write(buffer.ToString()); + } + } + } + } + else if (keyInfo.Key == ConsoleKey.DownArrow) + { + if (_historyIndex < _history.Count) + { + _historyIndex++; + lock (ConsoleLock) + { + int currentLength = buffer.Length; + for (int i = 0; i < currentLength; i++) + { + Console.Write("\b \b"); + } + + buffer.Clear(); + if (_historyIndex == _history.Count) + { + buffer.Append(_tempInput); + } + else + { + buffer.Append(_history[_historyIndex]); + } + cursor = buffer.Length; + + CurrentInput = buffer.ToString(); + CursorPosition = cursor; + + Console.Write(buffer.ToString()); + } + } + } + else if (keyInfo.Key == ConsoleKey.LeftArrow) + { + if (cursor > 0) + { + lock (ConsoleLock) + { + cursor--; + CursorPosition = cursor; + Console.Write("\b"); + } + } + } + else if (keyInfo.Key == ConsoleKey.RightArrow) + { + if (cursor < buffer.Length) + { + lock (ConsoleLock) + { + Console.Write(buffer[cursor]); + cursor++; + CursorPosition = cursor; + } + } + } + else if (keyInfo.KeyChar != '\0') + { + char c = keyInfo.KeyChar; + lock (ConsoleLock) + { + buffer.Insert(cursor, c); + cursor++; + CurrentInput = buffer.ToString(); + CursorPosition = cursor; + + Console.Write(c); + if (cursor < buffer.Length) + { + string remaining = buffer.ToString().Substring(cursor); + Console.Write(remaining); + for (int i = 0; i < remaining.Length; i++) + { + Console.Write("\b"); + } + } + } + } + } + + return null; + } + + private List GetCompletions(string text, out int prefixLength) + { + prefixLength = 0; + var completions = new List(); + var parts = ParseArgumentsForCompletion(text); + bool endsWithSpace = text.Length > 0 && text[text.Length - 1] == ' '; + + if (parts.Count == 0 || (parts.Count == 1 && !endsWithSpace)) + { + string word = parts.Count == 1 ? parts[0] : ""; + prefixLength = word.Length; + completions.AddRange(PrimaryCommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + else + { + string primaryCmd = parts[0].ToLowerInvariant(); + + if (primaryCmd == "reload") + { + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(ReloadSubcommands); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(ReloadSubcommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + else if (primaryCmd == "list") + { + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(ListSubcommands); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(ListSubcommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + else if (primaryCmd == "blockedips") + { + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(BlockedIpsSubcommands); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(BlockedIpsSubcommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + else if (primaryCmd == "kick") + { + var onlinePlayerNames = Envir.Main.Players.Select(p => p.Name).ToList(); + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(onlinePlayerNames); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(onlinePlayerNames.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + else if (primaryCmd == "gm") + { + var onlinePlayerNames = Envir.Main.Players.Select(p => p.Name).ToList(); + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(onlinePlayerNames); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(onlinePlayerNames.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + else if (primaryCmd == "player") + { + if (parts.Count == 1 && endsWithSpace) + { + var onlinePlayerNames = Envir.Main.Players.Select(p => p.Name).ToList(); + prefixLength = 0; + completions.AddRange(onlinePlayerNames); + } + else if (parts.Count == 2 && !endsWithSpace) + { + var onlinePlayerNames = Envir.Main.Players.Select(p => p.Name).ToList(); + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(onlinePlayerNames.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + else if (parts.Count == 2 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(PlayerSubcommands); + } + else if (parts.Count == 3 && !endsWithSpace) + { + string word = parts[2]; + prefixLength = word.Length; + completions.AddRange(PlayerSubcommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + else if (parts.Count == 3 && endsWithSpace) + { + string sub = parts[2].ToLowerInvariant(); + if (sub == "edit") + { + prefixLength = 0; + completions.AddRange(PlayerEditStats); + } + } + else if (parts.Count == 4 && !endsWithSpace) + { + string sub = parts[2].ToLowerInvariant(); + if (sub == "edit") + { + string word = parts[3]; + prefixLength = word.Length; + completions.AddRange(PlayerEditStats.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } + } + } + + return completions; + } + + private List ParseArgumentsForCompletion(string text) + { + var list = new List(); + var sb = new StringBuilder(); + bool inQuotes = false; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (c == '"') + { + inQuotes = !inQuotes; + } + else if (c == ' ' && !inQuotes) + { + if (sb.Length > 0) + { + list.Add(sb.ToString()); + sb.Clear(); + } + } + else + { + sb.Append(c); + } + } + if (sb.Length > 0) + { + list.Add(sb.ToString()); + } + return list; + } + + private void ExecuteCommand(string line) + { + var parts = ParseArguments(line); + if (parts.Length == 0) return; + + string command = parts[0].ToLowerInvariant(); + + switch (command) + { + case "help": + case "?": + ShowHelp(); + break; + + case "status": + ShowStatus(); + break; + + case "start": + StartServer(); + break; + + case "stop": + StopServer(); + break; + + case "reboot": + case "restart": + RebootServer(); + break; + + case "exit": + case "quit": + ExitServer(); + break; + + case "reload": + ReloadConfig(parts); + break; + + case "say": + case "broadcast": + BroadcastAnnouncement(parts); + break; + + case "list": + case "online": + ListInfo(parts); + break; + + case "kick": + KickPlayer(parts); + break; + + case "blockedips": + case "ipban": + case "ipunban": + HandleIPBans(command, parts); + break; + + case "player": + HandlePlayerCommand(parts); + break; + + case "gm": + HandleGMCommand(parts); + break; + + default: + Print($"Unknown command '{command}'. Type 'help' for a list of commands."); + break; + } + } + + private string[] ParseArguments(string commandLine) + { + var parts = new List(); + var currentToken = new StringBuilder(); + bool inQuotes = false; + + for (int i = 0; i < commandLine.Length; i++) + { + char c = commandLine[i]; + + if (c == '\"') + { + inQuotes = !inQuotes; + continue; + } + + if (c == ' ' && !inQuotes) + { + if (currentToken.Length > 0) + { + parts.Add(currentToken.ToString()); + currentToken.Clear(); + } + } + else + { + currentToken.Append(c); + } + } + + if (currentToken.Length > 0) + { + parts.Add(currentToken.ToString()); + } + + return parts.ToArray(); + } + + private void ShowHelp() + { + Print("=== Headless Server Console Commands ==="); + Print(" help / ? - Displays this help message."); + Print(" status - Displays real-time server metrics (uptime, players, monsters, cycle delays, etc.)."); + Print(" start - Starts the server environment if stopped."); + Print(" stop - Stops the server environment."); + Print(" reboot / restart - Reboots the server environment."); + Print(" exit / quit - Gracefully stops the server and closes the application."); + Print(" reload - Reloads server configuration files."); + Print(" say / broadcast - Sends an announcement message to all online players."); + Print(" list - Lists all online players or registered guilds."); + Print(" kick [reason] - Kicks the specified player from the server."); + Print(" blockedips | remove > - Manages IP blocklist."); + Print(" player [args...] - Manages a specific player. (Type 'player help' for details)"); + Print(" gm - Simulates a chat box command or message for with temporary GM privileges."); + } + + private void ShowPlayerHelp() + { + Print("=== Player Management Subcommands ==="); + Print(" player status / info - Shows detailed player information."); + Print(" player edit - Modifies player stats."); + Print(" player message - Sends an announcement chat to the player."); + Print(" player kick - Disconnects the player."); + Print(" player kill - Kills the player."); + Print(" player killpets - Kills all the player's pets."); + Print(" player safezone - Teleports the player to their safe zone / bind point."); + Print(" player chatban - Bans the player from chatting for N minutes."); + Print(" player chatunban - Removes the player's chat ban."); + Print(" player ban - Bans the player's account for N minutes."); + Print(" player unban - Unbans the player's account."); + Print(" player flag - Enables or disables a flag on the player."); + } + + private void ShowStatus() + { + var envir = Envir.Main; + if (envir == null) + { + Print("Server Environment is not initialized."); + return; + } + + string runningState = envir.Running ? "RUNNING" : "STOPPED"; + var uptime = envir.Stopwatch.Elapsed; + string uptimeStr = $"{uptime.Days}d:{uptime.Hours}h:{uptime.Minutes}m:{uptime.Seconds}s"; + int playersCount = envir.Players.Count; + int monstersCount = envir.MonsterCount; + int connectionsCount = envir.Connections.Count; + int blockedIpsCount = Envir.IPBlocks.Count(x => x.Value > envir.Now); + + Print($"--- Server Status ({runningState}) ---"); + Print($" Uptime: {uptimeStr}"); + Print($" Active Players: {playersCount}"); + Print($" Active Monsters: {monstersCount}"); + Print($" TCP Connections: {connectionsCount}"); + Print($" Blocked IPs: {blockedIpsCount}"); + + if (Settings.Multithreaded && envir.MobThreads != null) + { + var cycleDelays = $"CycleDelays: {Envir.LastRunTime:0000}"; + for (int i = 0; i < envir.MobThreads.Length; i++) + { + if (envir.MobThreads[i] == null) break; + cycleDelays += $"|{envir.MobThreads[i].LastRunTime:0000}"; + } + Print($" {cycleDelays}"); + } + else + { + Print($" CycleDelay: {Envir.LastRunTime}"); + } + } + + private void StartServer() + { + if (Envir.Main.Running) + { + Print("Server is already running."); + return; + } + Print("Starting Server Environment..."); + Envir.Main.Start(); + } + + private void StopServer() + { + if (!Envir.Main.Running) + { + Print("Server is not running."); + return; + } + Print("Stopping Server Environment..."); + Envir.Main.Stop(); + Envir.Main.MonsterCount = 0; + Print("Server stopped."); + } + + private void RebootServer() + { + Print("Rebooting Server Environment..."); + Envir.Main.Reboot(); + } + + private void ExitServer() + { + Print("Shutdown command received. Commencing graceful termination..."); + _cts.Cancel(); + } + + private void ReloadConfig(string[] parts) + { + if (parts.Length < 2) + { + Print("Usage: reload "); + return; + } + + string target = parts[1].ToLowerInvariant(); + switch (target) + { + case "npc": + Envir.Main.ReloadNPCs(); + Print("NPC scripts reloaded."); + break; + case "drops": + Envir.Main.ReloadDrops(); + Print("Drop configs reloaded."); + break; + case "line": + Envir.Main.ReloadLineMessages(); + Print("Line messages reloaded."); + break; + case "all": + Envir.Main.ReloadNPCs(); + Envir.Main.ReloadDrops(); + Envir.Main.ReloadLineMessages(); + Print("NPCs, Drops, and Line messages reloaded."); + break; + default: + Print($"Unknown reload target '{target}'. Valid targets: npc, drops, line, all"); + break; + } + } + + private void BroadcastAnnouncement(string[] parts) + { + if (parts.Length < 2) + { + Print("Usage: say or broadcast "); + return; + } + + string message = string.Join(" ", parts.Skip(1)); + + foreach (var player in Envir.Main.Players) + { + player.ReceiveChat(message, ChatType.Announcement); + } + + MessageQueue.Instance.EnqueueChat(message); + Print($"[Broadcast] {message}"); + } + + private void ListInfo(string[] parts) + { + if (parts.Length < 2) + { + Print("Usage: list "); + return; + } + + string target = parts[1].ToLowerInvariant(); + if (target == "players" || target == "player") + { + var players = Envir.Main.Players; + Print($"--- Online Players ({players.Count}) ---"); + Print(string.Format("{0,-6} | {1,-15} | {2,-5} | {3,-10} | {4,-8} | {5}", "Index", "Name", "Level", "Class", "Gender", "Current Map")); + Print(new string('-', 75)); + foreach (var p in players) + { + string mapName = MapInfo.GetMapTitleByIndex(p.Info.CurrentMapIndex); + Print(string.Format("{0,-6} | {1,-15} | {2,-5} | {3,-10} | {4,-8} | {5}", + p.Info.Index, p.Name, p.Level, p.Class, p.Gender, mapName)); + } + } + else if (target == "guilds" || target == "guild") + { + var guilds = Envir.Main.GuildList; + Print($"--- Registered Guilds ({guilds.Count}) ---"); + Print(string.Format("{0,-6} | {1,-20} | {2,-15} | {3,-10} | {4,-5} | {5,-10} | {6}", + "Index", "Name", "Leader", "Members", "Level", "Gold", "Territory Rent")); + Print(new string('-', 90)); + foreach (var g in guilds) + { + string leaderName = "DELETED"; + if (g.Ranks.Count > 0 && g.Ranks[0].Members.Count > 0) + { + leaderName = g.Ranks[0].Members[0].Name; + } + + Print(string.Format("{0,-6} | {1,-20} | {2,-15} | {3,-10} | {4,-5} | {5,-10} | {6}", + g.GuildIndex, g.Name, leaderName, $"{g.Membercount}/{g.MemberCap}", g.Level, g.Gold, g.HasGT ? g.GTRent.ToString() : "None")); + } + } + else + { + Print($"Unknown list target '{target}'. Valid targets: players, guilds"); + } + } + + private void KickPlayer(string[] parts) + { + if (parts.Length < 2) + { + Print("Usage: kick [reason]"); + return; + } + + string playerName = parts[1]; + var player = Envir.Main.GetPlayer(playerName); + if (player == null) + { + Print($"Player '{playerName}' not found or is offline."); + return; + } + + string reason = parts.Length > 2 ? string.Join(" ", parts.Skip(2)) : "Kicked by administrator."; + Print($"Kicking player '{player.Name}' (Reason: {reason})"); + player.Connection.SendDisconnect(4); // Reason 4 is KickedByAdmin + } + + private void HandleGMCommand(string[] parts) + { + if (parts.Length < 3) + { + Print("Usage: gm "); + return; + } + + string playerName = parts[1]; + var player = Envir.Main.GetPlayer(playerName); + if (player == null) + { + Print($"Player '{playerName}' not found or is offline."); + return; + } + + string message = string.Join(" ", parts.Skip(2)); + bool originalIsGM = player.IsGM; + player.IsGM = true; + try + { + player.Chat(message); + } + finally + { + player.IsGM = originalIsGM; + } + } + + private void HandleIPBans(string command, string[] parts) + { + if (command == "ipban") + { + if (parts.Length < 2) + { + Print("Usage: ipban [days]"); + return; + } + double days = parts.Length > 2 && double.TryParse(parts[2], out double d) ? d : 365; + Envir.Main.UpdateIPBlock(parts[1], TimeSpan.FromDays(days)); + Print($"IP '{parts[1]}' has been blocked for {days} days."); + return; + } + else if (command == "ipunban") + { + if (parts.Length < 2) + { + Print("Usage: ipunban "); + return; + } + if (Envir.IPBlocks.TryRemove(parts[1], out _)) + { + Print($"IP '{parts[1]}' has been unblocked."); + } + else + { + Print($"IP '{parts[1]}' was not found in the blocklist."); + } + return; + } + + if (parts.Length < 2) + { + Print("Usage: blockedips [days] | remove >"); + return; + } + + string action = parts[1].ToLowerInvariant(); + switch (action) + { + case "list": + var activeBlocks = Envir.IPBlocks.Where(x => x.Value > Envir.Main.Now).ToList(); + Print($"--- Blocked IPs ({activeBlocks.Count}) ---"); + foreach (var block in activeBlocks) + { + Print($" {block.Key} (Expires: {block.Value})"); + } + break; + + case "clear": + Envir.IPBlocks.Clear(); + Print("IP blocklist cleared."); + break; + + case "add": + if (parts.Length < 3) + { + Print("Usage: blockedips add [days]"); + return; + } + double addDays = parts.Length > 3 && double.TryParse(parts[3], out double ad) ? ad : 365; + Envir.Main.UpdateIPBlock(parts[2], TimeSpan.FromDays(addDays)); + Print($"IP '{parts[2]}' has been blocked for {addDays} days."); + break; + + case "remove": + if (parts.Length < 3) + { + Print("Usage: blockedips remove "); + return; + } + if (Envir.IPBlocks.TryRemove(parts[2], out _)) + { + Print($"IP '{parts[2]}' has been unblocked."); + } + else + { + Print($"IP '{parts[2]}' was not found in the blocklist."); + } + break; + + default: + Print($"Unknown blockedips action '{action}'. Valid actions: list, clear, add, remove"); + break; + } + } + + private void HandlePlayerCommand(string[] parts) + { + if (parts.Length < 3) + { + ShowPlayerHelp(); + return; + } + + string playerName = parts[1]; + string action = parts[2].ToLowerInvariant(); + + CharacterInfo charInfo = Envir.Main.GetCharacterInfo(playerName); + if (charInfo == null) + { + Print($"Character '{playerName}' not found in database."); + return; + } + + PlayerObject player = charInfo.Player; + + switch (action) + { + case "status": + case "info": + PrintPlayerStatus(charInfo); + break; + + case "edit": + if (parts.Length < 5) + { + Print("Usage: player edit "); + return; + } + EditPlayerStat(charInfo, parts[3].ToLowerInvariant(), parts[4]); + break; + + case "message": + case "msg": + if (player == null) + { + Print($"Player '{playerName}' is offline."); + return; + } + if (parts.Length < 4) + { + Print("Usage: player message "); + return; + } + string msg = string.Join(" ", parts.Skip(3)); + player.ReceiveChat(msg, ChatType.Announcement); + Print($"Sent message to '{playerName}': {msg}"); + break; + + case "kick": + if (player == null) + { + Print($"Player '{playerName}' is offline."); + return; + } + Print($"Kicking player '{playerName}'..."); + player.Connection.SendDisconnect(4); + break; + + case "kill": + if (player == null) + { + Print($"Player '{playerName}' is offline."); + return; + } + Print($"Killing player '{playerName}'..."); + player.Die(); + break; + + case "killpets": + if (player == null) + { + Print($"Player '{playerName}' is offline."); + return; + } + Print($"Killing all pets for player '{playerName}'..."); + for (int i = player.Pets.Count - 1; i >= 0; i--) + { + player.Pets[i].Die(); + } + break; + + case "safezone": + if (player == null) + { + Print($"Player '{playerName}' is offline."); + return; + } + Print($"Teleporting player '{playerName}' to safe zone..."); + player.Teleport(Envir.Main.GetMap(charInfo.BindMapIndex), charInfo.BindLocation); + break; + + case "chatban": + if (parts.Length < 4 || !int.TryParse(parts[3], out int chatBanMins)) + { + Print("Usage: player chatban "); + return; + } + charInfo.ChatBanned = true; + charInfo.ChatBanExpiryDate = Envir.Main.Now.AddMinutes(chatBanMins); + Print($"Player '{playerName}' chat-banned for {chatBanMins} minutes (Expires: {charInfo.ChatBanExpiryDate})."); + if (player != null) + { + player.ReceiveChat($"You have been chat-banned by the administrator for {chatBanMins} minutes.", ChatType.System); + } + break; + + case "chatunban": + charInfo.ChatBanned = false; + charInfo.ChatBanExpiryDate = DateTime.MinValue; + Print($"Player '{playerName}' chat-unbanned."); + if (player != null) + { + player.ReceiveChat("Your chat ban has been removed by the administrator.", ChatType.System); + } + break; + + case "ban": + if (parts.Length < 4 || !int.TryParse(parts[3], out int banMins)) + { + Print("Usage: player ban "); + return; + } + if (charInfo.AccountInfo.AdminAccount) + { + Print("Cannot ban an administrator account."); + return; + } + charInfo.AccountInfo.Banned = true; + charInfo.AccountInfo.ExpiryDate = Envir.Main.Now.AddMinutes(banMins); + Print($"Player '{playerName}' account banned for {banMins} minutes (Expires: {charInfo.AccountInfo.ExpiryDate})."); + if (player != null) + { + player.Connection.SendDisconnect(6); + } + break; + + case "unban": + charInfo.AccountInfo.Banned = false; + charInfo.AccountInfo.ExpiryDate = DateTime.MinValue; + Print($"Player '{playerName}' account unbanned."); + break; + + case "flag": + if (parts.Length < 5) + { + Print("Usage: player flag "); + return; + } + if (!int.TryParse(parts[3], out int flagIndex) || flagIndex < 0 || flagIndex >= charInfo.Flags.Length) + { + Print($"Invalid flag index. Must be between 0 and {charInfo.Flags.Length - 1}."); + return; + } + string flagValue = parts[4].ToLowerInvariant(); + if (flagValue == "enable" || flagValue == "true" || flagValue == "active" || flagValue == "1") + { + charInfo.Flags[flagIndex] = true; + Print($"Flag {flagIndex} enabled for '{playerName}'."); + } + else if (flagValue == "disable" || flagValue == "false" || flagValue == "inactive" || flagValue == "0") + { + charInfo.Flags[flagIndex] = false; + Print($"Flag {flagIndex} disabled for '{playerName}'."); + } + else + { + Print("Invalid flag value. Use 'enable' or 'disable'."); + } + break; + + default: + Print($"Unknown player subcommand '{action}'. Type 'player' for details."); + break; + } + } + + private void PrintPlayerStatus(CharacterInfo charInfo) + { + Print($"=== Player Status: {charInfo.Name} ==="); + Print($" Index: {charInfo.Index}"); + Print($" Level: {charInfo.Level}"); + Print($" Class: {charInfo.Class}"); + Print($" Gender: {charInfo.Gender}"); + Print($" Gold: {charInfo.AccountInfo.Gold:n0}"); + Print($" Credit (GameGold): {charInfo.AccountInfo.Credit:n0}"); + Print($" PK Points: {charInfo.PKPoints}"); + Print($" Last IP: {charInfo.AccountInfo.LastIP}"); + Print($" Status: {(charInfo.Player != null ? "ONLINE" : "OFFLINE")}"); + + if (charInfo.Player != null) + { + var p = charInfo.Player; + string mapName = MapInfo.GetMapTitleByIndex(charInfo.CurrentMapIndex); + Print($" Current Map: {mapName} (X: {p.CurrentLocation.X}, Y: {p.CurrentLocation.Y})"); + double expPct = p.MaxExperience > 0 ? (double)p.Experience / p.MaxExperience : 0; + Print($" Experience: {p.Experience} / {p.MaxExperience} ({expPct:P2})"); + Print($" Stats: AC={p.Stats[Stat.MinAC]}-{p.Stats[Stat.MaxAC]}, MAC={p.Stats[Stat.MinMAC]}-{p.Stats[Stat.MaxMAC]}, DC={p.Stats[Stat.MinDC]}-{p.Stats[Stat.MaxDC]}, MC={p.Stats[Stat.MinMC]}-{p.Stats[Stat.MaxMC]}, SC={p.Stats[Stat.MinSC]}-{p.Stats[Stat.MaxSC]}"); + Print($" Pets: {p.Pets.Count} active pets."); + } + + if (charInfo.ChatBanned) + { + Print($" Chat Banned: YES (Expires: {charInfo.ChatBanExpiryDate})"); + } + if (charInfo.AccountInfo.Banned) + { + Print($" Account Banned: YES (Expires: {charInfo.AccountInfo.ExpiryDate})"); + } + } + + private void EditPlayerStat(CharacterInfo charInfo, string statName, string rawValue) + { + switch (statName) + { + case "level": + if (byte.TryParse(rawValue, out byte lvl)) + { + charInfo.Level = lvl; + Print($"Set '{charInfo.Name}' level to {lvl}."); + if (charInfo.Player != null) + { + charInfo.Player.Level = lvl; + charInfo.Player.LevelUp(); + } + } + else + { + Print("Invalid level value. Must be a byte (0-255)."); + } + break; + + case "gold": + if (uint.TryParse(rawValue, out uint gold)) + { + charInfo.AccountInfo.Gold = gold; + Print($"Set '{charInfo.Name}' account gold to {gold:n0}."); + } + else + { + Print("Invalid gold value. Must be a non-negative integer."); + } + break; + + case "credit": + case "gamegold": + if (uint.TryParse(rawValue, out uint credit)) + { + charInfo.AccountInfo.Credit = credit; + Print($"Set '{charInfo.Name}' account credit to {credit:n0}."); + } + else + { + Print("Invalid credit value. Must be a non-negative integer."); + } + break; + + case "pk": + case "pkpoints": + if (int.TryParse(rawValue, out int pk)) + { + charInfo.PKPoints = pk; + Print($"Set '{charInfo.Name}' PK points to {pk}."); + } + else + { + Print("Invalid PK points value. Must be an integer."); + } + break; + + default: + Print($"Unknown editable stat '{statName}'. Editable stats: level, gold, credit, pk"); + break; + } + } + } +} diff --git a/Server.Headless/ConsoleServerHost.cs b/Server.Headless/ConsoleServerHost.cs new file mode 100644 index 000000000..cccdf4a08 --- /dev/null +++ b/Server.Headless/ConsoleServerHost.cs @@ -0,0 +1,35 @@ +using System; +using Server; + +namespace Server.Headless +{ + public class ConsoleServerHost : IServerHost + { + public bool IsRunning { get; private set; } = true; + + public void Log(string msg) + { + lock (CommandProcessor.ConsoleLock) + { + CommandProcessor.ClearInputLine(); + Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}"); + CommandProcessor.RedrawInputLine(); + } + } + + public void UpdatePlayerCount(int count) + { + lock (CommandProcessor.ConsoleLock) + { + CommandProcessor.ClearInputLine(); + Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Active Players: {count}"); + CommandProcessor.RedrawInputLine(); + } + } + + public void Shutdown() + { + IsRunning = false; + } + } +} diff --git a/Server.Headless/Program.cs b/Server.Headless/Program.cs new file mode 100644 index 000000000..10d73ab66 --- /dev/null +++ b/Server.Headless/Program.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using log4net; +using log4net.Config; +using Server; +using Server.MirEnvir; + +namespace Server.Headless +{ + class Program + { + static async Task Main(string[] args) + { + Packet.IsServer = true; + + // Configure log4net + var logRepository = LogManager.GetRepository(Assembly.GetEntryAssembly()); + XmlConfigurator.Configure(logRepository, new FileInfo("log4net.config")); + + var host = new ConsoleServerHost(); + Envir.Initialise(host); + + host.Log("Starting Legend of Mir Crystal Server (Headless Mode)..."); + + try + { + Settings.Load(); + + host.Log("Loading Database..."); + bool dbLoaded = Envir.Edit.LoadDB(); + if (!dbLoaded) + { + host.Log("[CRITICAL] Failed to load server database. Exiting."); + return; + } + + host.Log("Starting Server Environment..."); + Envir.Main.Start(); + + host.Log("Server successfully started. Press Ctrl+C or type 'exit' to stop."); + + var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (sender, e) => + { + e.Cancel = true; // Prevent immediate termination + host.Log("Shutdown signal received. Commencing graceful termination..."); + cts.Cancel(); + }; + + var commandProcessor = new CommandProcessor(host, cts); + await commandProcessor.RunAsync(); + + host.Log("Stopping Server Environment..."); + Envir.Main.Stop(); + + host.Log("Saving configurations and database state..."); + Settings.Save(); + + host.Log("Server shutdown complete."); + } + catch (Exception ex) + { + Logger.GetLogger().Error("Fatal execution error in Headless server host", ex); + host.Log($"[FATAL] {ex}"); + } + } + } +} diff --git a/Server.Headless/README.md b/Server.Headless/README.md new file mode 100644 index 000000000..c2298c6f0 --- /dev/null +++ b/Server.Headless/README.md @@ -0,0 +1,70 @@ +# Legend of Mir Crystal - Headless Server + +This project is the modernized, headless version of the Legend of Mir Crystal Server, fully migrated to **.NET 10.0**. It is designed specifically to run natively and efficiently on Linux environments without any dependencies on Windows-specific UI frameworks (like Windows Forms) or image rendering libraries. + +## 🌟 Features +- **True Headless Design:** Fully decoupled from UI forms; runs directly as a console application. +- **High-Precision Tick Loop:** Utilizes a custom Spin-Loop throttling mechanism for hyper-accurate 1ms tick alignment without yielding to the Linux OS scheduler, guaranteeing zero timing dilation. +- **Native Linux Path Resolution:** Automatically normalizes directory separators and enforces consistent lowercasing for map loaders to seamlessly resolve paths on case-sensitive Linux filesystems. +- **Lightweight Serialization:** Features a hand-rolled, type-safe binary serialization protocol for the buff system, fully replacing the obsolete and insecure `BinaryFormatter`. + +--- + +## 🛠️ Prerequisites + +To run this server natively, your environment must have: +- **OS:** Linux (Ubuntu/Debian, CentOS, AlmaLinux, etc.) or Windows (cross-platform compatible). +- **Runtime:** [.NET 10.0 SDK or Runtime](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) + +--- + +## 🚀 Deployment & Execution + +### 1. Building the Server +To compile the headless server from the source code, navigate to the root solution directory and run: +```bash +dotnet build Server.Headless/Server.Headless.csproj -c Release +``` +This will compile the `Server.Headless`, `Server.Library`, and `Shared` projects and output the executable to `Server.Headless/bin/Release/net10.0/`. + +### 2. Environment Setup +The server requires specific data folders to operate. Place your built executable alongside the following core directories (often found in the release `Jev` package): +- `Server.MirDB` (Your main database file) +- `Configs/` (Server settings and configurations) +- `Envir/` (Drops, NPCs, Quests, Routes, and Scripts) +- `Maps/` (Game map `.map` files) + +*Note: If you are migrating maps from a Windows server, it is highly recommended to batch-rename all your `.map` files to lowercase to ensure native case-sensitive resolution.* + +### 3. Running the Server +You can launch the server directly using the `.NET` CLI from the directory containing your environment files: +```bash +dotnet run --project path/to/Server.Headless.csproj -c Release +``` +Or run the published executable directly: +```bash +./Server.Headless +``` + +### 4. Graceful Shutdown +To stop the server safely, press `Ctrl + C` in the terminal. The headless host intercepts the `SIGINT` signal to halt the game loop, securely save the database state, and commit all configurations before fully exiting. + +--- + +## ⚙️ Configuration + +### Game Settings (`Configs/Settings.ini`) +When the server runs for the first time, it will automatically generate or load `Settings.ini` inside the `Configs/` directory. You can edit this file to adjust ports, experience rates, drop rates, and other environmental properties. + +### Logging (`log4net.config`) +Console output and log files are managed by `log4net`. The logging configuration is located in `log4net.config` at the root of the executable directory. +- Logs are automatically saved to `Logs/Server/`, `Logs/Chat/`, `Logs/Player/`, etc. +- By default, Linux-native forward slashes are utilized in the log paths. + +--- + +## 🔧 Troubleshooting + +- **Script Not Found Errors:** If the server console reports `Script Not Found` or `INSERT Script Not Found`, ensure that all custom scripts referenced in your `Envir/` directory exist and that their internal references (`#INCLUDE`, `#INSERT`) correctly match your file structure. +- **Failed to Load Map:** Ensure your map files are properly placed in the `Maps/` directory and are named in strictly lowercase (e.g., `d011.map` instead of `D011.map`) to satisfy Linux filesystem requirements. +- **Address Already in Use:** If the server fails to bind the network socket, ensure no other instance of the server is running in the background and that your configured port (default TCP `7000`) is free. diff --git a/Server.Headless/Server.Headless.csproj b/Server.Headless/Server.Headless.csproj new file mode 100644 index 000000000..f0d23d68d --- /dev/null +++ b/Server.Headless/Server.Headless.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + disable + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/Server.Headless/log4net.config b/Server.Headless/log4net.config new file mode 100644 index 000000000..5463485a0 --- /dev/null +++ b/Server.Headless/log4net.config @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Server/IServerHost.cs b/Server/IServerHost.cs new file mode 100644 index 000000000..84cbb0d81 --- /dev/null +++ b/Server/IServerHost.cs @@ -0,0 +1,9 @@ +namespace Server +{ + public interface IServerHost + { + void Log(string msg); + void UpdatePlayerCount(int count); + bool IsRunning { get; } + } +} diff --git a/Server/MessageQueue.cs b/Server/MessageQueue.cs index 765a23950..88408c195 100644 --- a/Server/MessageQueue.cs +++ b/Server/MessageQueue.cs @@ -23,6 +23,7 @@ public void Enqueue(string msg) MessageLog.Enqueue(String.Format("[{0}]: {1}" + Environment.NewLine, DateTime.Now, msg)); Logger.GetLogger(LogType.Server).Info(msg); + Server.MirEnvir.Envir.Host?.Log(msg); } public void Enqueue(Exception ex) @@ -31,6 +32,7 @@ public void Enqueue(Exception ex) MessageLog.Enqueue(String.Format("[{0}]: {1} - {2}" + Environment.NewLine, DateTime.Now, ex.TargetSite, ex)); Logger.GetLogger(LogType.Server).Error(ex); + Server.MirEnvir.Envir.Host?.Log(ex.ToString()); } public void EnqueueDebugging(string msg) diff --git a/Server/MirDatabase/MonsterInfo.cs b/Server/MirDatabase/MonsterInfo.cs index 492ffdce3..143004e45 100644 --- a/Server/MirDatabase/MonsterInfo.cs +++ b/Server/MirDatabase/MonsterInfo.cs @@ -1,4 +1,4 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; using Server.MirEnvir; namespace Server.MirDatabase @@ -493,7 +493,7 @@ private static List ParseInsert(List lines) if (!match.Success) continue; - var subPath = match.Groups[1].Value; + var subPath = match.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar); string path = Path.Combine(Settings.DropPath, subPath); diff --git a/Server/MirDatabase/QuestInfo.cs b/Server/MirDatabase/QuestInfo.cs index 6562561bf..a797aea6e 100644 --- a/Server/MirDatabase/QuestInfo.cs +++ b/Server/MirDatabase/QuestInfo.cs @@ -1,4 +1,4 @@ -using Server.MirObjects; +using Server.MirObjects; using System.Text.RegularExpressions; using Server.MirEnvir; @@ -133,7 +133,8 @@ public void LoadInfo(bool clear = false) if (!Directory.Exists(Settings.QuestPath)) return; - string fileName = Path.Combine(Settings.QuestPath, FileName + ".txt"); + string normalizedPath = FileName.Replace('\\', Path.DirectorySeparatorChar); + string fileName = Path.Combine(Settings.QuestPath, normalizedPath + ".txt"); if (File.Exists(fileName)) { diff --git a/Server/MirEnvir/Envir.cs b/Server/MirEnvir/Envir.cs index 2f02c4ec3..8ed6f938d 100644 --- a/Server/MirEnvir/Envir.cs +++ b/Server/MirEnvir/Envir.cs @@ -44,9 +44,15 @@ public int Next(int minValue, int maxValue) => public class Envir { public static Envir Main { get; } = new Envir(); - public static Envir Edit { get; } = new Envir(); + public static IServerHost Host { get; private set; } + + public static void Initialise(IServerHost host) + { + Host = host; + } + protected static MessageQueue MessageQueue => MessageQueue.Instance; public static object AccountLock = new object(); @@ -2050,10 +2056,18 @@ private void WorkLoop() } try { + int lastPlayerCount = -1; while (Running) { + long tickStartTicks = Stopwatch.ElapsedTicks; Time = Stopwatch.ElapsedMilliseconds; + if (Players.Count != lastPlayerCount) + { + lastPlayerCount = Players.Count; + Host?.UpdatePlayerCount(lastPlayerCount); + } + if (Time >= processTime) { LastCount = processCount; @@ -2174,8 +2188,11 @@ private void WorkLoop() }); } - // if (Players.Count == 0) Thread.Sleep(1); - // GC.Collect(); + // Pattern B: Manually align the execution time to exactly 10,000 ticks (1ms) using SpinWait + while (Stopwatch.ElapsedTicks - tickStartTicks < 10000) + { + Thread.SpinWait(10); + } } } catch (Exception ex) @@ -5403,7 +5420,7 @@ public void ReloadDrops() if (!string.IsNullOrEmpty(MonsterInfoList[i].DropPath)) { - path = Path.Combine(Settings.DropPath, MonsterInfoList[i].DropPath + ".txt"); + path = Path.Combine(Settings.DropPath, MonsterInfoList[i].DropPath.Replace('\\', Path.DirectorySeparatorChar) + ".txt"); } MonsterInfoList[i].Drops.Clear(); diff --git a/Server/MirEnvir/Map.cs b/Server/MirEnvir/Map.cs index 9e4f2dcfb..cf4098c97 100644 --- a/Server/MirEnvir/Map.cs +++ b/Server/MirEnvir/Map.cs @@ -433,7 +433,8 @@ public bool Load() { try { - string fileName = Path.Combine(Settings.MapPath, Info.FileName + ".map"); + string normalizedPath = Info.FileName.Replace('\\', Path.DirectorySeparatorChar).ToLowerInvariant(); + string fileName = Path.Combine(Settings.MapPath, normalizedPath + ".map"); if (File.Exists(fileName)) { byte[] fileBytes = File.ReadAllBytes(fileName); @@ -2573,7 +2574,8 @@ public void LoadRoutes() if (string.IsNullOrEmpty(Info.RoutePath)) return; - string fileName = Path.Combine(Settings.RoutePath, Info.RoutePath + ".txt"); + string normalizedPath = Info.RoutePath.Replace('\\', Path.DirectorySeparatorChar).ToLowerInvariant(); + string fileName = Path.Combine(Settings.RoutePath, normalizedPath + ".txt"); if (!File.Exists(fileName)) return; diff --git a/Server/MirObjects/NPC/NPCScript.cs b/Server/MirObjects/NPC/NPCScript.cs index 279a9419d..63ec137a9 100644 --- a/Server/MirObjects/NPC/NPCScript.cs +++ b/Server/MirObjects/NPC/NPCScript.cs @@ -139,7 +139,7 @@ public void LoadInfo() if (!Directory.Exists(Settings.NPCPath)) return; - string fileName = Path.Combine(Settings.NPCPath, FileName + ".txt"); + string fileName = Path.Combine(Settings.NPCPath, FileName.Replace('\\', Path.DirectorySeparatorChar) + ".txt"); if (File.Exists(fileName)) { @@ -312,7 +312,7 @@ private List ParseInsert(List lines) if (split.Length < 2) continue; - string path = Path.Combine(Settings.EnvirPath, split[1].Substring(1, split[1].Length - 2)); + string path = Path.Combine(Settings.EnvirPath, split[1].Substring(1, split[1].Length - 2).Replace('\\', Path.DirectorySeparatorChar)); if (!File.Exists(path)) MessageQueue.Enqueue(GameLanguage.ServerTextMap.GetLocalization((ServerTextKeys.InsertScriptNotFound), path)); @@ -335,7 +335,7 @@ private List ParseInclude(List lines) string[] split = lines[i].Split(' '); - string path = Path.Combine(Settings.EnvirPath, split[1].Substring(1, split[1].Length - 2)); + string path = Path.Combine(Settings.EnvirPath, split[1].Substring(1, split[1].Length - 2).Replace('\\', Path.DirectorySeparatorChar)); string page = ("[" + split[2] + "]").ToUpper(); bool start = false, finish = false; diff --git a/Server/MirObjects/NPC/NPCSegment.cs b/Server/MirObjects/NPC/NPCSegment.cs index ee13d03d1..7daa5b5fc 100644 --- a/Server/MirObjects/NPC/NPCSegment.cs +++ b/Server/MirObjects/NPC/NPCSegment.cs @@ -1007,7 +1007,7 @@ public void ParseAct(List acts, string line) if (quoteMatch.Success) { - fileName = Path.Combine(Settings.ValuePath, quoteMatch.Groups[1].Captures[0].Value); + fileName = Path.Combine(Settings.ValuePath, quoteMatch.Groups[1].Captures[0].Value.Replace('\\', Path.DirectorySeparatorChar)); string group = parts[parts.Length - 2]; string key = parts[parts.Length - 1]; @@ -1029,7 +1029,7 @@ public void ParseAct(List acts, string line) if (matchCol.Count > 0 && matchCol[0].Success) { - fileName = Path.Combine(Settings.ValuePath, matchCol[0].Groups[1].Captures[0].Value); + fileName = Path.Combine(Settings.ValuePath, matchCol[0].Groups[1].Captures[0].Value.Replace('\\', Path.DirectorySeparatorChar)); string value = parts[parts.Length - 1]; @@ -1148,7 +1148,7 @@ public void ParseAct(List acts, string line) if (quoteMatch.Success) listPath = quoteMatch.Groups[1].Captures[0].Value; - fileName = Path.Combine(Settings.DropPath, listPath); + fileName = Path.Combine(Settings.DropPath, listPath.Replace('\\', Path.DirectorySeparatorChar)); acts.Add(new NPCActions(ActionType.Drop, fileName)); break; diff --git a/Server/Server.Library.csproj b/Server/Server.Library.csproj index 5991ac74d..4e1741e65 100644 --- a/Server/Server.Library.csproj +++ b/Server/Server.Library.csproj @@ -1,8 +1,8 @@ - + Library - net8.0 + net10.0 disable enable diff --git a/Server/Settings.cs b/Server/Settings.cs index feffa29a4..708e5c1f1 100644 --- a/Server/Settings.cs +++ b/Server/Settings.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Security.Cryptography; using Server.MirDatabase; using Server.MirObjects; @@ -618,7 +618,7 @@ public static void Load() LoadWorldMap(); LoadHeroSettings(); - string languageDirectory = @".\Localization\"; + string languageDirectory = Path.Combine(AppContext.BaseDirectory, "Localization"); if (!Directory.Exists(languageDirectory)) { Directory.CreateDirectory(languageDirectory); diff --git a/Shared/Functions/Functions.cs b/Shared/Functions/Functions.cs index 7db983e65..12d99f276 100644 --- a/Shared/Functions/Functions.cs +++ b/Shared/Functions/Functions.cs @@ -1,6 +1,5 @@ -using System.Drawing; +using System.Drawing; using System.IO.Compression; -using System.Runtime.Serialization.Formatters.Binary; public static class Functions { @@ -434,25 +433,48 @@ public static byte[] CombineArray(List arrays) } public static byte[] SerializeToBytes(T item) { -#pragma warning disable SYSLIB0011 - var formatter = new BinaryFormatter(); using (var stream = new MemoryStream()) + using (var writer = new BinaryWriter(stream)) { - formatter.Serialize(stream, item); - stream.Seek(0, SeekOrigin.Begin); -#pragma warning restore SYSLIB0011 + if (item is bool b) + { + writer.Write((byte)1); + writer.Write(b); + } + else if (item is long l) + { + writer.Write((byte)2); + writer.Write(l); + } + else if (item is string s) + { + writer.Write((byte)3); + writer.Write(s); + } + else + { + writer.Write((byte)0); + } return stream.ToArray(); } } public static object DeserializeFromBytes(byte[] bytes) { -#pragma warning disable SYSLIB0011 - var formatter = new BinaryFormatter(); using (var stream = new MemoryStream(bytes)) + using (var reader = new BinaryReader(stream)) { - var deserialized = formatter.Deserialize(stream); -#pragma warning restore SYSLIB0011 - return deserialized; + byte type = reader.ReadByte(); + switch (type) + { + case 1: + return reader.ReadBoolean(); + case 2: + return reader.ReadInt64(); + case 3: + return reader.ReadString(); + default: + return null; + } } } diff --git a/Shared/Functions/IniReader.cs b/Shared/Functions/IniReader.cs index 8b8532ab1..f909acdfe 100644 --- a/Shared/Functions/IniReader.cs +++ b/Shared/Functions/IniReader.cs @@ -1,4 +1,4 @@ -using System.Drawing; +using System.Drawing; public class InIReader { @@ -12,7 +12,7 @@ public InIReader(string fileName) { _fileName = fileName; - if (!Directory.Exists(Path.GetDirectoryName(fileName))) + if (!string.IsNullOrEmpty(Path.GetDirectoryName(fileName)) && !Directory.Exists(Path.GetDirectoryName(fileName))) { Directory.CreateDirectory(Path.GetDirectoryName(fileName)); } diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj index fc8ebeece..7849a1220 100644 --- a/Shared/Shared.csproj +++ b/Shared/Shared.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable disable From c87e31cd784800d2d62f035dfba3a19925335117 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 15:44:15 +0900 Subject: [PATCH 02/31] Fixing Collection Modification Exception on Hide Game Interface We have identified and fixed the runtime error that occurred when hiding the game interface (camera mode). --- Client/MirControls/MirControl.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Client/MirControls/MirControl.cs b/Client/MirControls/MirControl.cs index 48600b45a..2364fb745 100644 --- a/Client/MirControls/MirControl.cs +++ b/Client/MirControls/MirControl.cs @@ -585,8 +585,11 @@ protected virtual void OnVisibleChanged() if (Controls != null) - foreach (MirControl control in Controls) - control.OnVisibleChanged(); + { + MirControl[] temp = Controls.ToArray(); + foreach (MirControl control in temp) + control?.OnVisibleChanged(); + } } protected void OnBeforeShown() { From 0eab1eca90dfb8e48f1af299607efc3e08b78c8a Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 17:09:12 +0900 Subject: [PATCH 03/31] chore: update FNA submodules, sync framework dependencies, and add hero base stats configurations We resolved the issue where mouse-hover tooltips (such as for active buff icons) and the debug overlay did not render on the Linux FNA client. Additionally, we conducted a heuristic search and resolved three other platform-gap issues on FNA/Linux. --- Client/Platform/FNA/FNAEntry.cs | 36 ++++ Client/Platform/MirInputTypes.cs | 268 ++++++++++++++++++++++++++++- Client/Settings.cs | 2 +- Cross-platformPortingExperience.md | 20 +++ 4 files changed, 321 insertions(+), 5 deletions(-) diff --git a/Client/Platform/FNA/FNAEntry.cs b/Client/Platform/FNA/FNAEntry.cs index 1d5e2ec3b..b1360ecf9 100644 --- a/Client/Platform/FNA/FNAEntry.cs +++ b/Client/Platform/FNA/FNAEntry.cs @@ -107,6 +107,13 @@ protected override void Update(GameTime gameTime) for (int i = 0; i < MirAnimatedButton.Animations.Count; i++) MirAnimatedButton.Animations[i].UpdateOffSet(); + + CMain.CreateHintLabel(); + + if (Settings.DebugMode) + { + CMain.CreateDebugLabel(); + } } private void PollKeyboard() @@ -129,6 +136,33 @@ private void PollKeyboard() CMain.Ctrl = (modifiers & MirKeys.Control) == MirKeys.Control; CMain.Alt = (modifiers & MirKeys.Alt) == MirKeys.Alt; + // Handle Screenshot globally on key release + foreach (var key in _prevKeyboardState.GetPressedKeys()) + { + if (!currState.IsKeyDown(key)) + { + var mirKey = (MirKeys)(int)key; + bool isScreenshot = false; + foreach (var keyCheck in CMain.InputKeys.Keylist) + { + if (keyCheck.function != KeybindOptions.Screenshot) continue; + if (keyCheck.Key != mirKey) continue; + if (keyCheck.RequireAlt != 2 && keyCheck.RequireAlt != (CMain.Alt ? 1 : 0)) continue; + if (keyCheck.RequireShift != 2 && keyCheck.RequireShift != (CMain.Shift ? 1 : 0)) continue; + if (keyCheck.RequireCtrl != 2 && keyCheck.RequireCtrl != (CMain.Ctrl ? 1 : 0)) continue; + if (keyCheck.RequireTilde != 2 && keyCheck.RequireTilde != (CMain.Tilde ? 1 : 0)) continue; + + isScreenshot = true; + break; + } + + if (isScreenshot) + { + CMain.CreateScreenShot(); + } + } + } + if (Client.MirControls.MirControl.ActiveControl is Client.MirControls.MirTextBox textBox) { // Key Down events @@ -242,6 +276,8 @@ protected override void Draw(GameTime gameTime) { if (Renderer == null) return; + CMain.UpdateFrameTime(); + // Clear screen Renderer.Clear(System.Drawing.Color.Black); diff --git a/Client/Platform/MirInputTypes.cs b/Client/Platform/MirInputTypes.cs index 9bf30d4ea..755215a13 100644 --- a/Client/Platform/MirInputTypes.cs +++ b/Client/Platform/MirInputTypes.cs @@ -496,6 +496,17 @@ public enum SpriteFlags namespace Client { + using System.Drawing; + using System.Windows.Forms; + using Client.MirControls; + using Client.MirGraphics; + using Client.MirObjects; + using Client.MirScenes; + using Client.Platform.FNA; + using Shared; + using System.Linq; + using System.IO; + public static class CMain { public static long Time; @@ -507,15 +518,80 @@ public static class CMain public static MouseCursor CurrentCursor = MouseCursor.None; public static void SetMouseCursor(MouseCursor cursor) { CurrentCursor = cursor; } - public static bool IsKeyLocked(Client.Platform.MirKeys key) => false; + public static bool IsKeyLocked(Client.Platform.MirKeys key) + { + if (key == Client.Platform.MirKeys.Capital) + { + try + { + return Console.CapsLock; + } + catch + { + return false; + } + } + return false; + } + + public static void CreateScreenShot() + { + if (FNAEntry.Instance == null || FNAEntry.Instance.GraphicsDevice == null) return; + + try + { + var device = FNAEntry.Instance.GraphicsDevice; + int width = device.PresentationParameters.BackBufferWidth; + int height = device.PresentationParameters.BackBufferHeight; + + Microsoft.Xna.Framework.Color[] colors = new Microsoft.Xna.Framework.Color[width * height]; + device.GetBackBufferData(colors); + + using (var image = new SixLabors.ImageSharp.Image(width, height)) + { + image.ProcessPixelRows(accessor => + { + for (int y = 0; y < height; y++) + { + var row = accessor.GetRowSpan(y); + for (int x = 0; x < width; x++) + { + var c = colors[y * width + x]; + row[x] = new SixLabors.ImageSharp.PixelFormats.Rgba32(c.R, c.G, c.B, c.A); + } + } + }); + + string path = Path.Combine(AppContext.BaseDirectory, "Screenshots"); + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + + int count = Directory.GetFiles(path, "*.png").Length; + string fileName = Path.Combine(path, $"Image {count}.png"); + SixLabors.ImageSharp.ImageExtensions.SaveAsPng(image, fileName); + } + } + catch (Exception ex) + { + SaveError($"Screenshot Error: {ex.Message}"); + } + } public static DateTime Now => DateTime.Now; public static Random Random = new Random(); public static bool SpellTargetLock; public static long PingTime; public static string DebugText = string.Empty; - public static Client.MirControls.MirLabel DebugBaseLabel; - public static Client.MirControls.MirLabel HintBaseLabel; + + public static Client.MirControls.MirControl DebugBaseLabel, HintBaseLabel; + public static Client.MirControls.MirLabel DebugTextLabel, HintTextLabel; + + public static int FPS; + public static int DPS; + public static int DPSCounter; + private static long _fpsTime; + private static int _fps; + public static uint BytesReceived; public static uint BytesSent; public static long NextPing; @@ -536,11 +612,195 @@ public static void SaveError(string ex) public static void SetResolution(int width, int height) { - // Handled dynamically under Linux via FNA GraphicsDeviceManager + if (Settings.ScreenWidth == width && Settings.ScreenHeight == height) return; + + Settings.ScreenWidth = width; + Settings.ScreenHeight = height; + + if (FNAEntry.Instance != null) + { + FNAEntry.Instance.Graphics.PreferredBackBufferWidth = width; + FNAEntry.Instance.Graphics.PreferredBackBufferHeight = height; + FNAEntry.Instance.Graphics.ApplyChanges(); + + if (FNAEntry.Instance.Renderer != null) + { + FNAEntry.Instance.Renderer.Initialize(width, height, Settings.FullScreen); + FNAEntry.Instance.Renderer.SetViewport(0, 0, width, height); + } + } } private static System.Drawing.Bitmap _dummyBmp = new System.Drawing.Bitmap(1, 1); public static System.Drawing.Graphics Graphics = System.Drawing.Graphics.FromImage(_dummyBmp); + + public static void UpdateFrameTime() + { + if (Time >= _fpsTime) + { + _fpsTime = Time + 1000; + FPS = _fps; + _fps = 0; + + DPS = DPSCounter; + DPSCounter = 0; + } + else + _fps++; + } + + public static void CreateHintLabel() + { + if (HintBaseLabel == null || HintBaseLabel.IsDisposed) + { + HintBaseLabel = new MirControl + { + BackColour = Color.FromArgb(255, 0, 0, 0), + Border = true, + DrawControlTexture = true, + BorderColour = Color.FromArgb(255, 144, 144, 0), + ForeColour = Color.Yellow, + Parent = MirScene.ActiveScene, + NotControl = true, + Opacity = 0.5F + }; + } + + if (HintTextLabel == null || HintTextLabel.IsDisposed) + { + HintTextLabel = new MirLabel + { + AutoSize = true, + BackColour = Color.Transparent, + ForeColour = Color.Yellow, + Parent = HintBaseLabel, + NotControl = true, + }; + + HintTextLabel.SizeChanged += (o, e) => HintBaseLabel.Size = HintTextLabel.Size; + } + + if (MirControl.MouseControl == null || string.IsNullOrEmpty(MirControl.MouseControl.Hint)) + { + HintBaseLabel.Visible = false; + return; + } + + HintBaseLabel.Visible = true; + + HintTextLabel.Text = MirControl.MouseControl.Hint; + + Point point = MPoint.Add(-HintTextLabel.Size.Width, 20); + + if (point.X + HintBaseLabel.Size.Width >= Settings.ScreenWidth) + point.X = Settings.ScreenWidth - HintBaseLabel.Size.Width - 1; + if (point.Y + HintBaseLabel.Size.Height >= Settings.ScreenHeight) + point.Y = Settings.ScreenHeight - HintBaseLabel.Size.Height - 1; + + if (point.X < 0) + point.X = 0; + if (point.Y < 0) + point.Y = 0; + + HintBaseLabel.Location = point; + } + + public static void CreateDebugLabel() + { + string text; + + if (MirControl.MouseControl != null) + { + text = string.Format("FPS: {0}", FPS); + + text += string.Format(", DPS: {0}", DPS); + + text += string.Format(", Time: {0:HH:mm:ss UTC}", Now); + + if (MirControl.MouseControl is MapControl) + text += string.Format(", Co Ords: {0}", MapControl.MapLocation); + + if (MirControl.MouseControl is MirImageControl) + text += string.Format(", Control: {0}", MirControl.MouseControl.GetType().Name); + + if (MirScene.ActiveScene is GameScene) + text += string.Format(", Objects: {0}", MapControl.Objects.Count); + + if (MirScene.ActiveScene is GameScene && !string.IsNullOrEmpty(DebugText)) + text += string.Format(", Debug: {0}", DebugText); + + if (MirObjects.MapObject.MouseObject != null) + { + text += string.Format(", Target: {0}", MirObjects.MapObject.MouseObject.Name); + } + else + { + text += string.Format(", Target: none"); + } + } + else + { + text = string.Format("FPS: {0}", FPS); + } + + text += string.Format(", Ping: {0}", PingTime); + + text += string.Format(", Sent: {0}, Received: {1}", Functions.ConvertByteSize(BytesSent), Functions.ConvertByteSize(BytesReceived)); + + text += string.Format(", TLC: {0}", DXManager.TextureList.Count(x => x.TextureValid)); + text += string.Format(", CLC: {0}", DXManager.ControlList.Count(x => x.IsDisposed == false)); + + if (Settings.FullScreen) + { + if (DebugBaseLabel == null || DebugBaseLabel.IsDisposed) + { + DebugBaseLabel = new MirControl + { + BackColour = Color.FromArgb(50, 50, 50), + Border = true, + BorderColour = Color.Black, + DrawControlTexture = true, + Location = new Point(5, 5), + NotControl = true, + Opacity = 0.5F, + Parent = MirScene.ActiveScene, + }; + } + + if (DebugTextLabel == null || DebugTextLabel.IsDisposed) + { + DebugTextLabel = new MirLabel + { + AutoSize = true, + BackColour = Color.Transparent, + ForeColour = Color.White, + Parent = DebugBaseLabel, + }; + + DebugTextLabel.SizeChanged += (o, e) => DebugBaseLabel.Size = DebugTextLabel.Size; + } + + DebugTextLabel.Text = text; + } + else + { + if (DebugBaseLabel != null && DebugBaseLabel.IsDisposed == false) + { + DebugBaseLabel.Dispose(); + DebugBaseLabel = null; + } + if (DebugTextLabel != null && DebugTextLabel.IsDisposed == false) + { + DebugTextLabel.Dispose(); + DebugTextLabel = null; + } + + if (FNAEntry.Instance != null) + { + FNAEntry.Instance.Window.Title = $"{GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.GameName)} - {text}"; + } + } + } } } #endif diff --git a/Client/Settings.cs b/Client/Settings.cs index c896c4254..1fc732c9f 100644 --- a/Client/Settings.cs +++ b/Client/Settings.cs @@ -372,7 +372,7 @@ public static void Save() Reader.Write("Game", "TargetDead", TargetDead); Reader.Write("Game", "HighlightTarget", HighlightTarget); Reader.Write("Game", "ExpandedBuffWindow", ExpandedBuffWindow); - Reader.Write("Game", "ExpandedHeroBuffWindow", ExpandedBuffWindow); + Reader.Write("Game", "ExpandedHeroBuffWindow", ExpandedHeroBuffWindow); Reader.Write("Game", "DuraWindow", DuraView); Reader.Write("Game", "DisplayBodyName", DisplayBodyName); Reader.Write("Game", "NewMove", NewMove); diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index d9dca2eec..c555f7dc5 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -152,6 +152,26 @@ All major porting regressions—specifically map/ground rendering, blend-state v * **The Problem:** When implementing cave and nighttime lighting effects, switching between render targets (drawing the light mask onto a custom `LightRenderTarget` and then switching back to the default backbuffer to multiply blend) caused the entire game screen to turn pitch black under multiplicative blending. Switching to opaque blending proved the light geometries were drawn correctly, but the previously rendered game scene was completely lost. * **The Solution:** In FNA/XNA, the default backbuffer presentation parameters initialize with `RenderTargetUsage = RenderTargetUsage.DiscardContents`. Under Vulkan or modern OpenGL graphics drivers, changing the active render target ends the render pass on the default swapchain backbuffer. When switching back via `SetRenderTarget(null)` to start a new render pass, Vulkan uses a discard load operation (`VK_ATTACHMENT_LOAD_OP_DONT_CARE` or `VK_ATTACHMENT_LOAD_OP_CLEAR`), erasing the previously rendered game scene. We fixed this by subscribing to the `PreparingDeviceSettings` event on `GraphicsDeviceManager` and explicitly setting `e.GraphicsDeviceInformation.PresentationParameters.RenderTargetUsage = RenderTargetUsage.PreserveContents` during initialization and resolution/viewport changes. This forces the Vulkan driver to load and preserve the swapchain image contents across render target changes. +### 2.26 Buff Status Hover Text & Tooltip Coordinate Syncing +* **The Problem:** In the Windows client, hover-over tooltips (like player status and buff icons next to the minimap) and debug label coordinate updates were handled by `Forms/CMain.cs` events. Since this directory is excluded from compilation under FNA/Linux, tooltips were completely silent, coordinates were never updated, and hover text failed to render. +* **The Solution:** We re-implemented `CMain.CreateHintLabel()`, `CMain.CreateDebugLabel()`, and `CMain.UpdateFrameTime()` as fully operational rendering stubs in the FNA-compilable `CMain` class in `Platform/MirInputTypes.cs`. We then hooked `UpdateFrameTime()` inside the main `Draw` loop in `FNAEntry.cs` and `CreateHintLabel()` and `CreateDebugLabel()` inside the main `Update` loop to keep tooltip label coordinates and active text synchronized with the mouse state. + +### 2.27 Dynamic Resolution Resizing +* **The Problem:** The `CMain.SetResolution(width, height)` method was stubbed out to be empty under Linux. This meant selecting a different screen resolution or toggling window modes did not resize the client window or scale the graphics presentation viewport, keeping the resolution locked to the initial value read from the config file. +* **The Solution:** We implemented the FNA version of `SetResolution` to dynamically configure the preferred backbuffer width/height on the `GraphicsDeviceManager`, trigger `ApplyChanges()` to resize the window, and re-initialize/update the viewport on the active `FNARenderer` instance. + +### 2.28 Keyboard Caps Lock State Checks +* **The Problem:** The virtual keyboard (`InputKeyDialog`) used the Win32-specific `CMain.IsKeyLocked` method to determine the state of the CAPS LOCK key, which always returned `false` on Linux. This prevented the virtual keyboard from performing uppercase case-switching when CAPS LOCK was active. +* **The Solution:** We updated `IsKeyLocked` inside `MirInputTypes.cs` to leverage the cross-platform `.NET` `Console.CapsLock` property, wrapping the call in a try/catch to safely return `false` if the client is executed in a headless/non-console environment. + +### 2.29 Cross-platform GPU Screen Captures / Screenshots +* **The Problem:** The legacy client screenshot routine relied on GDI+ WinForms device contexts (`Program.Form.CreateScreenShot()`), which is absent under FNA/Linux. Consequently, pressing the screenshot key had no effect. +* **The Solution:** We built a custom `CMain.CreateScreenShot()` routine in the FNA client. It captures raw color pixels directly from the GPU backbuffer via `GraphicsDevice.GetBackBufferData()`, converts the colors to an ImageSharp image (`Image`), and saves it as a PNG file inside the client's `Screenshots/` directory. We then wired a global print-screen keybind check into the keyboard polling cycle of `FNAEntry.PollKeyboard()`. + +### 2.30 Settings Serialization Bug +* **The Problem:** When saving client configuration, the settings writer erroneously serialized the player's `ExpandedBuffWindow` setting to the INI file under the `ExpandedHeroBuffWindow` key. +* **The Solution:** Fixed the field reference in `Client/Settings.cs` to correctly save the `ExpandedHeroBuffWindow` setting under its own key. + --- ## 3. Structural Porting Guidelines for Future Reference From c4c19e307e6e20b2f9ce19015d9bbc9f000caf01 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 17:34:40 +0900 Subject: [PATCH 04/31] Fix Modifier Key Binding Registration In the FNA (Linux) version of the client, pressing Ctrl, Alt, or Shift to bind a key combination (e.g., Ctrl + A) immediately binds the hotkey as Ctrl + LControlKey, Alt + LMenu, or Shift + LShiftKey without waiting for the rest of the key combination. --- Client/MirScenes/Dialogs/KeyboardLayoutDialog.cs | 7 +++++-- Cross-platformPortingExperience.md | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Client/MirScenes/Dialogs/KeyboardLayoutDialog.cs b/Client/MirScenes/Dialogs/KeyboardLayoutDialog.cs index e901793e8..5a040522b 100644 --- a/Client/MirScenes/Dialogs/KeyboardLayoutDialog.cs +++ b/Client/MirScenes/Dialogs/KeyboardLayoutDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirSounds; @@ -300,7 +300,10 @@ private void PositionBar_OnMoving(object sender, MouseEventArgs e) public void CheckNewInput(KeyEventArgs e) { - if (e.KeyCode == Keys.ControlKey || e.KeyCode == Keys.Menu || e.KeyCode == Keys.ShiftKey || e.KeyCode == Keys.Oem8 || e.KeyCode == Keys.None) return; + if (e.KeyCode == Keys.ControlKey || e.KeyCode == Keys.LControlKey || e.KeyCode == Keys.RControlKey || + e.KeyCode == Keys.Menu || e.KeyCode == Keys.LMenu || e.KeyCode == Keys.RMenu || + e.KeyCode == Keys.ShiftKey || e.KeyCode == Keys.LShiftKey || e.KeyCode == Keys.RShiftKey || + e.KeyCode == Keys.Oem8 || e.KeyCode == Keys.None) return; KeyBind bind = CMain.InputKeys.Keylist.Single(x => x.function == WaitingForBind.function); diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index c555f7dc5..e6239bf0e 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -172,6 +172,14 @@ All major porting regressions—specifically map/ground rendering, blend-state v * **The Problem:** When saving client configuration, the settings writer erroneously serialized the player's `ExpandedBuffWindow` setting to the INI file under the `ExpandedHeroBuffWindow` key. * **The Solution:** Fixed the field reference in `Client/Settings.cs` to correctly save the `ExpandedHeroBuffWindow` setting under its own key. +### 2.31 FNA Client Hotkey Binding Modifiers Bug +* **The Problem:** When setting hotkeys in the Keyboard Layout dialog under FNA (Linux), pressing a modifier key (Ctrl, Alt, or Shift) immediately registered the keybind as `Ctrl + LControlKey`, `Alt + LMenu`, or `Shift + LShiftKey` rather than waiting for the player to press the primary key of the combination. +* **The Solution:** Under FNA/Linux, modifier keys poll as specific physical key codes (`Keys.LControlKey`, `Keys.RControlKey`, `Keys.LMenu`, `Keys.RMenu`, `Keys.LShiftKey`, `Keys.RShiftKey`). The dialog input capture method `KeyboardLayoutDialog.CheckNewInput` was ignoring generic modifier values (`Keys.ControlKey`, `Keys.Menu`, `Keys.ShiftKey`), but failed to filter out the side-specific keys. We updated `CheckNewInput` to ignore both left- and right-handed specific modifier key codes so that key registration waits until the primary key of the combination is pressed. + +### 2.32 UI Visibility Toggle Collection Modification Bug +* **The Problem:** When using the camera mode hotkey to hide/show the game interface, the client would crash with a `System.InvalidOperationException: Collection was modified; enumeration operation may not execute` runtime error. This was caused by the recursive propagation of `OnVisibleChanged()` down the control tree. During enumeration of the parent's `Controls` collection, any child control with `Sort = true` attempted to re-order itself inside the parent's collection by calling `Parent.Controls.Remove(this)` and `Parent.Controls.Add(this)`, mutating the collection under iteration. +* **The Solution:** We updated `MirControl.OnVisibleChanged()` to copy the `Controls` list to a temporary array (`Controls.ToArray()`) before enumerating it, ensuring that structural sorting changes do not interfere with the active control visibility propagation loop. + --- ## 3. Structural Porting Guidelines for Future Reference From 20d62cf991f1c22699cb5c879ae542f2a01b9b22 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 22:50:24 +0900 Subject: [PATCH 05/31] FNA Rendering Corrections 1. Rebirth3 Red Square Rendering Correction 2. Missing Minimap/Poison Indicator Dots & Blending Rate Fixes --- Client/MirGraphics/MLibrary.cs | 33 ++++++++++++++++++++++++++++++ Client/Platform/FNA/FNARenderer.cs | 19 ++++++++++++++++- Cross-platformPortingExperience.md | 4 ++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/Client/MirGraphics/MLibrary.cs b/Client/MirGraphics/MLibrary.cs index 2a03c9217..ac9fefdbb 100644 --- a/Client/MirGraphics/MLibrary.cs +++ b/Client/MirGraphics/MLibrary.cs @@ -1006,6 +1006,25 @@ public unsafe void CreateTexture(BinaryReader reader) byte g = rawBytes[byteIdx + 1]; byte r = rawBytes[byteIdx + 2]; byte a = rawBytes[byteIdx + 3]; + + // Clear extremely dark background noise (e.g. 8,0,0) that was originally meant to be transparent + // but becomes a prominent square rendering artifact due to sRGB gamma correction under FNA (Vulkan/OpenGL). + // This only targets pure/almost-pure dark primary channels (Red, Green, Blue <= 8) with A == 255. + if (a == 255 && + ((r <= 8 && g == 0 && b == 0) || + (r == 0 && g <= 8 && b == 0) || + (r == 0 && g == 0 && b <= 8))) + { + r = 0; + g = 0; + b = 0; + a = 0; + rawBytes[byteIdx] = 0; + rawBytes[byteIdx + 1] = 0; + rawBytes[byteIdx + 2] = 0; + rawBytes[byteIdx + 3] = 0; + } + pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); } } @@ -1043,6 +1062,20 @@ public unsafe void CreateTexture(BinaryReader reader) byte g = rawBytes[byteIdx + 1]; byte r = rawBytes[byteIdx + 2]; byte a = rawBytes[byteIdx + 3]; + + // Clear extremely dark background noise (e.g. 8,0,0) that was originally meant to be transparent + // but becomes a prominent square rendering artifact due to sRGB gamma correction under FNA (Vulkan/OpenGL). + if (a == 255 && + ((r <= 8 && g == 0 && b == 0) || + (r == 0 && g <= 8 && b == 0) || + (r == 0 && g == 0 && b <= 8))) + { + r = 0; + g = 0; + b = 0; + a = 0; + } + pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); } } diff --git a/Client/Platform/FNA/FNARenderer.cs b/Client/Platform/FNA/FNARenderer.cs index 285fe2add..77285abfe 100644 --- a/Client/Platform/FNA/FNARenderer.cs +++ b/Client/Platform/FNA/FNARenderer.cs @@ -45,6 +45,17 @@ public FNARenderer(GraphicsDevice device) _whiteTexture = new Texture2D(Device, 1, 1); _whiteTexture.SetData(new[] { Microsoft.Xna.Framework.Color.White }); + // Initialize RadarTexture and PoisonDotBackground for minimap and poison indicators + var radarColors = new Microsoft.Xna.Framework.Color[4 * 4]; + for (int i = 0; i < radarColors.Length; i++) radarColors[i] = Microsoft.Xna.Framework.Color.White; + Client.MirGraphics.DXManager.RadarTexture = new Texture2D(Device, 4, 4); + Client.MirGraphics.DXManager.RadarTexture.SetData(radarColors); + + var poisonColors = new Microsoft.Xna.Framework.Color[6 * 6]; + for (int i = 0; i < poisonColors.Length; i++) poisonColors[i] = Microsoft.Xna.Framework.Color.White; + Client.MirGraphics.DXManager.PoisonDotBackground = new Texture2D(Device, 6, 6); + Client.MirGraphics.DXManager.PoisonDotBackground.SetData(poisonColors); + _additiveBlendState = new BlendState { ColorSourceBlend = Blend.SourceAlpha, @@ -138,7 +149,11 @@ public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect else SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState); - Draw(texture, sourceRect, position, color); + var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * rate; + var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); + + SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); SpriteBatch.End(); @@ -320,6 +335,8 @@ public void Dispose() _whiteTexture?.Dispose(); _additiveBlendState?.Dispose(); _multiplyBlendState?.Dispose(); + Client.MirGraphics.DXManager.RadarTexture?.Dispose(); + Client.MirGraphics.DXManager.PoisonDotBackground?.Dispose(); } } } diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index e6239bf0e..01dac606e 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -23,6 +23,10 @@ All major porting regressions—specifically map/ground rendering, blend-state v 1. **Standard Blending:** Changed the default `SpriteBatch` pipeline to draw with `BlendState.NonPremultiplied`. This ensures raw `.wil` texture alpha channels are interpolated without darkening background pixels. 2. **Additive Blending (`SetBlend`):** In legacy SlimDX, `DXManager.SetBlend(true)` changed global DirectX pipeline state, allowing subsequent `Sprite.Draw()` calls to blend additively. FNA's `DXManager.Draw` originally bypassed this entirely, ignoring the `Blending` state and causing spells to render with solid black background boxes. We updated `DXManager.Draw` and `DrawOpaque` to detect the `Blending` state and route draws to `Renderer.DrawBlend()`, restoring native Vulkan/OpenGL additive blending support. 3. **Radial GPU Lights:** Corrected vertex color interpolation inside `FNARenderer.RenderGPULights`. Edges of lights were interpolating RGB channels to zero (`Color.Transparent`), producing dark gray borders. Using `Color(R, G, B, 0)` for outer vertices preserves color chromaticity while fading to transparent. + 4. **sRGB Gamma Correction & Color-Key Workarounds:** Legacy 16-bit BMP assets used off-black key colors like `(8, 0, 0, 255)` (Red = 1) to prevent older engines from keying them out as transparent black. Under DirectX 9, these rendered as dark, virtually invisible red squares. On Vulkan/OpenGL backbuffers under FNA, sRGB gamma correction maps linear `8` to `48` (~19% brightness), rendering them as prominent, semi-transparent red blocks (e.g. player-centered red square on `Rebirth3`). We resolved this by updating the BGRA-to-RGBA conversion loop in `MLibrary.cs` to filter out these dark workarounds (`R/G/B <= 8` with alpha 255 and other channels at 0) and clear them to transparent black `(0, 0, 0, 0)`. + 5. **Uninitialized Texture Indicators & Ignored Blend Rates:** + - In GDI+/DirectX 9, white placeholder textures of size 2x2 and 5x5 (`RadarTexture` and `PoisonDotBackground`) were generated to render map location markers (minimap and bigmap dots) and character poison indicators. Under FNA, these fields were uninitialized (`null`), rendering them invisible. We initialized them dynamically as solid white textures inside the `FNARenderer` constructor and registered them for safe disposal. + - The blending `rate` (opacity/fade) parameter in `IGraphicsRenderer.DrawBlend` was ignored under FNA, causing additive elements (like spell animations) to lose transition and fade-out effects. We patched `DrawBlend` in `FNARenderer.cs` to apply the `rate` parameter directly to the drawn colors, restoring correct opacity/fade transitions. ### 2.3 dialogue Layout & Font Measurement Parity * **The Problem:** Legacy dialog rendering (NPC dialogues, bulletin boards, quest logs, chat links, scrolling labels) relied on GDI+ (`TextRenderer.MeasureText`). Because GDI+ inherently pads bounding boxes, the game's original logic appended extra space characters and subtracted `10` or `11` pixels from measurements to force links/colored text to align. The new `FNATextRenderer` uses a pixel-perfect `SpriteFont` measurement which has zero padding. Applying these GDI+ "hacks" shifted colored/interactive text fragments to the left, causing them to overlay and overlap regular text. From cc2b7c837d3f7c785b92045e6f153e60d12edf19 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 21 May 2026 23:45:28 +0900 Subject: [PATCH 06/31] Implement Opacity and Grayscale Rendering in FNA Client We implemented global opacity and grayscale rendering support in the FNA graphics renderer to resolve visual parity regressions under the FNA port. --- Client/MirScenes/GameScene.cs | 11 +++++++ Client/Platform/FNA/FNARenderer.cs | 52 ++++++++++++++++++++++++++---- Cross-platformPortingExperience.md | 10 ++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 808686fc5..9a38bb7d6 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -10553,6 +10553,12 @@ public override void Draw() #if FNA if (User == null) return; + bool oldGrayScale = DXManager.GrayScale; + if (MapObject.User != null && MapObject.User.Dead) + { + DXManager.SetGrayscale(true); + } + DrawBackground(); // Draw Floor directly @@ -10939,6 +10945,11 @@ public override void Draw() if (MapObject.User.MouseOver(MouseLocation)) MapObject.User.DrawName(); + + if (MapObject.User != null && MapObject.User.Dead) + { + DXManager.SetGrayscale(oldGrayScale); + } #endif } diff --git a/Client/Platform/FNA/FNARenderer.cs b/Client/Platform/FNA/FNARenderer.cs index 77285abfe..b87bab3cb 100644 --- a/Client/Platform/FNA/FNARenderer.cs +++ b/Client/Platform/FNA/FNARenderer.cs @@ -1,6 +1,7 @@ using System; using System.Drawing; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Client.Platform; @@ -27,6 +28,9 @@ public class FNARenderer : IGraphicsRenderer, IDisposable private MatrixType _transformMatrix = MatrixType.Identity; private bool _hasTransform = false; private Texture2D _whiteTexture; + private float _opacity = 1f; + private bool _grayscale = false; + private readonly ConditionalWeakTable _grayscaleCache = new ConditionalWeakTable(); private readonly BlendState _additiveBlendState; private readonly BlendState _multiplyBlendState; private readonly VertexPositionColor[] _radialLightVertices; @@ -77,7 +81,7 @@ public FNARenderer(GraphicsDevice device) public void DrawRectangle(System.Drawing.Rectangle rect, System.Drawing.Color color, float opacity) { - var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity; + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity * _opacity; var destRect = new Microsoft.Xna.Framework.Rectangle(rect.X, rect.Y, rect.Width, rect.Height); SpriteBatch.Draw(_whiteTexture, destRect, xnaColor); } @@ -123,8 +127,12 @@ public void SetViewport(int x, int y, int width, int height) public void Draw(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color) { + if (_grayscale && texture is Texture2D t2d) + { + texture = GetGrayscaleTexture(t2d); + } var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); - var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A); + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * _opacity; var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); @@ -132,8 +140,12 @@ public void Draw(TextureHandle texture, System.Drawing.Rectangle sourceRect, Sys public void DrawOpaque(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, float opacity) { + if (_grayscale && texture is Texture2D t2d) + { + texture = GetGrayscaleTexture(t2d); + } var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); - var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity; + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * opacity * _opacity; var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); @@ -141,6 +153,10 @@ public void DrawOpaque(TextureHandle texture, System.Drawing.Rectangle sourceRec public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, float rate) { + if (_grayscale && texture is Texture2D t2d) + { + texture = GetGrayscaleTexture(t2d); + } // Set additive/blended states for special FX SpriteBatch.End(); @@ -150,7 +166,7 @@ public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState); var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); - var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * rate; + var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * rate * _opacity; var destRect = new Microsoft.Xna.Framework.Rectangle(position.X, position.Y, sourceRect.Width, sourceRect.Height); SpriteBatch.Draw(texture, destRect, xnaRect, xnaColor); @@ -288,6 +304,30 @@ public TextureHandle LoadTexture(string path) } } + private Texture2D GetGrayscaleTexture(Texture2D original) + { + if (_grayscaleCache.TryGetValue(original, out var grayscale)) + return grayscale; + + int width = original.Width; + int height = original.Height; + var pixels = new Microsoft.Xna.Framework.Color[width * height]; + original.GetData(pixels); + + for (int i = 0; i < pixels.Length; i++) + { + var color = pixels[i]; + if (color.A == 0) continue; + byte gray = (byte)(color.R * 0.299f + color.G * 0.587f + color.B * 0.114f); + pixels[i] = new Microsoft.Xna.Framework.Color(gray, gray, gray, color.A); + } + + var cached = new Texture2D(Device, width, height, false, SurfaceFormat.Color); + cached.SetData(pixels); + _grayscaleCache.Add(original, cached); + return cached; + } + public void SetTransform(MatrixType matrix) { _transformMatrix = matrix; @@ -314,12 +354,12 @@ public void SetSurface(object surface) public void SetGrayscale(bool value) { - // Grayscale rendering state handled in main pixel shaders + _grayscale = value; } public void SetOpacity(float opacity) { - // Opacity drawing state integrated natively in spritebatch calls + _opacity = opacity; } public void SetBlend(bool blend, float rate = 1f, Client.MirGraphics.BlendMode mode = Client.MirGraphics.BlendMode.Normal) diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 01dac606e..81b18acb6 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -184,6 +184,16 @@ All major porting regressions—specifically map/ground rendering, blend-state v * **The Problem:** When using the camera mode hotkey to hide/show the game interface, the client would crash with a `System.InvalidOperationException: Collection was modified; enumeration operation may not execute` runtime error. This was caused by the recursive propagation of `OnVisibleChanged()` down the control tree. During enumeration of the parent's `Controls` collection, any child control with `Sort = true` attempted to re-order itself inside the parent's collection by calling `Parent.Controls.Remove(this)` and `Parent.Controls.Add(this)`, mutating the collection under iteration. * **The Solution:** We updated `MirControl.OnVisibleChanged()` to copy the `Controls` list to a temporary array (`Controls.ToArray()`) before enumerating it, ensuring that structural sorting changes do not interfere with the active control visibility propagation loop. +### 2.33 Building Opacity Parity +* **The Problem:** In the SlimDX client, when the player walks behind a tall building, the client renders their silhouette on top of the building at 40% opacity. In the FNA client, the player was rendered fully solid, making it look as if they were standing on top of the building. This happened because `FNARenderer.SetOpacity` was a no-op stub, and the drawing methods in `FNARenderer` ignored the global opacity value. +* **The Solution:** We introduced a private `_opacity` field in `FNARenderer.cs`, mapped `SetOpacity` to update it, and updated all relevant drawing methods (`Draw`, `DrawOpaque`, `DrawBlend`, and `DrawRectangle`) to multiply their drawing colors by `_opacity`, restoring the semi-transparent silhouette behind structures. + +### 2.34 Grayscale Rendering System +* **The Problem:** The game client relies on grayscale rendering for disabled UI buttons, trust merchant slot items, and character death states. Under SlimDX, this was handled via a custom pixel shader (`grayscale.ps`). Under FNA, `SetGrayscale` was a no-op stub, and compiling or loading custom MojoShader effects at runtime on Linux poses package and toolchain dependencies. Furthermore, because the FNA client rendering path bypasses WinForms/SlimDX control compositing (`DrawControl`), character death did not trigger any grayscale effect. +* **The Solution:** + 1. **CPU-based Texture Conversion Cache:** We implemented a thread-safe weak-key cache using `ConditionalWeakTable` in `FNARenderer.cs`. When grayscale is active, drawing methods dynamically retrieve a cached grayscale copy of the texture. If not cached, the original texture pixels are extracted via `GetData()`, converted to grayscale on the CPU using standard luminosity weights (`R * 0.299 + G * 0.587 + B * 0.114`), and uploaded as a new texture using `SetData()`. + 2. **GameScene Rendering Integration:** We updated the FNA-specific `Draw()` method in `GameScene.cs` to check if `MapObject.User.Dead` is true, enabling grayscale rendering state prior to drawing the scene elements and restoring it afterward. + --- ## 3. Structural Porting Guidelines for Future Reference From 151bcc464305c7e09af06e216f22168f98726be0 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 22 May 2026 00:04:12 +0900 Subject: [PATCH 07/31] Dragged Item Render Depth Fix under FNA The inventory item and message lines render depth issue has been resolved for the FNA client target. --- Client/MirControls/MirControl.cs | 2 +- Client/MirScenes/GameScene.cs | 26 ++++++++++++++++++++++++++ Cross-platformPortingExperience.md | 6 ++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Client/MirControls/MirControl.cs b/Client/MirControls/MirControl.cs index 2364fb745..609ca9c47 100644 --- a/Client/MirControls/MirControl.cs +++ b/Client/MirControls/MirControl.cs @@ -797,7 +797,7 @@ protected virtual void DrawBorder() } #endif } - protected void AfterDrawControl() + protected virtual void AfterDrawControl() { if (AfterDraw != null) AfterDraw.Invoke(this, EventArgs.Empty); diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 9a38bb7d6..927e351ad 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -1167,6 +1167,31 @@ protected internal override void DrawControl() MapControl.DrawControl(); base.DrawControl(); +#if !FNA + if (PickedUpGold || (SelectedCell != null && SelectedCell.Item != null)) + { + int image = PickedUpGold ? 116 : SelectedCell.Item.Image; + Size imgSize = Libraries.Items.GetTrueSize(image); + Point p = CMain.MPoint.Add(-imgSize.Width / 2, -imgSize.Height / 2); + + if (p.X + imgSize.Width >= Settings.ScreenWidth) + p.X = Settings.ScreenWidth - imgSize.Width; + + if (p.Y + imgSize.Height >= Settings.ScreenHeight) + p.Y = Settings.ScreenHeight - imgSize.Height; + + Libraries.Items.Draw(image, p.X, p.Y); + } + + for (int i = 0; i < OutputLines.Length; i++) + OutputLines[i].Draw(); +#endif + } + +#if FNA + protected override void AfterDrawControl() + { + base.AfterDrawControl(); if (PickedUpGold || (SelectedCell != null && SelectedCell.Item != null)) { @@ -1186,6 +1211,7 @@ protected internal override void DrawControl() for (int i = 0; i < OutputLines.Length; i++) OutputLines[i].Draw(); } +#endif public override void Process() { if (MapControl == null || User == null) diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 81b18acb6..f3f2612fc 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -194,6 +194,12 @@ All major porting regressions—specifically map/ground rendering, blend-state v 1. **CPU-based Texture Conversion Cache:** We implemented a thread-safe weak-key cache using `ConditionalWeakTable` in `FNARenderer.cs`. When grayscale is active, drawing methods dynamically retrieve a cached grayscale copy of the texture. If not cached, the original texture pixels are extracted via `GetData()`, converted to grayscale on the CPU using standard luminosity weights (`R * 0.299 + G * 0.587 + B * 0.114`), and uploaded as a new texture using `SetData()`. 2. **GameScene Rendering Integration:** We updated the FNA-specific `Draw()` method in `GameScene.cs` to check if `MapObject.User.Dead` is true, enabling grayscale rendering state prior to drawing the scene elements and restoring it afterward. +### 2.35 Dragged Item & Text Layering Order under FNA +* **The Problem:** In the FNA client, dragging an inventory item (or gold) resulted in the item rendering *behind* dialog boxes (like the inventory or shop). Under SlimDX/DirectX, `GameScene.DrawControl()` rendered the dragged item and output message lines *after* executing `base.DrawControl()` (which drew all child controls/dialogs to a composite texture). Under FNA, composite texture rendering is disabled, and `MirScene` draws in immediate mode: it invokes `DrawControl()` (drawing the dragged item/lines) before calling `DrawChildControls()` (drawing the dialogs). Consequently, dialogs rendered on top of the dragged item. +* **The Solution:** + 1. **Virtual Post-Draw Lifecycle Hook:** Changed `AfterDrawControl` from `protected void` to `protected virtual void` in `MirControl.cs` to allow polymorphism and enable custom post-render logic. + 2. **Deferred Rendering in GameScene:** In `GameScene.cs`, we excluded the dragged item/gold and screen output text lines from rendering inside `DrawControl()` under the `#if FNA` directive. Instead, we added an override of `AfterDrawControl()` specifically under `#if FNA` to render these overlays at the very end of the control draw cycle—after both `DrawControl()` and `DrawChildControls()` have completed—correctly restoring top-layer rendering. + --- ## 3. Structural Porting Guidelines for Future Reference From cf2552e0c9ed2887dbaf02f1e7b2b5b93ee623e3 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 22 May 2026 01:11:46 +0900 Subject: [PATCH 08/31] Rendering Fix: Mapping Off-Black Key Colors to Opaque Black --- Client/MirGraphics/MLibrary.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Client/MirGraphics/MLibrary.cs b/Client/MirGraphics/MLibrary.cs index ac9fefdbb..c9e239611 100644 --- a/Client/MirGraphics/MLibrary.cs +++ b/Client/MirGraphics/MLibrary.cs @@ -1018,11 +1018,11 @@ public unsafe void CreateTexture(BinaryReader reader) r = 0; g = 0; b = 0; - a = 0; + a = 255; rawBytes[byteIdx] = 0; rawBytes[byteIdx + 1] = 0; rawBytes[byteIdx + 2] = 0; - rawBytes[byteIdx + 3] = 0; + rawBytes[byteIdx + 3] = 255; } pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); @@ -1073,7 +1073,7 @@ public unsafe void CreateTexture(BinaryReader reader) r = 0; g = 0; b = 0; - a = 0; + a = 255; } pixels[i] = (int)((a << 24) | (b << 16) | (g << 8) | r); From 64b5680fb571d9a68836d9b95343345b4f04ad5b Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 22 May 2026 10:12:57 +0900 Subject: [PATCH 09/31] Headless CLI Double-Width Character Support I have fixed the issue where the headless server console input shifts to the right when handling double-width characters (like Chinese characters in player names). --- Server.Headless/CommandProcessor.cs | 115 +++++++++++++++++++++------- 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/Server.Headless/CommandProcessor.cs b/Server.Headless/CommandProcessor.cs index 3b48cab19..cf2b2d821 100644 --- a/Server.Headless/CommandProcessor.cs +++ b/Server.Headless/CommandProcessor.cs @@ -42,12 +42,70 @@ public class CommandProcessor public static int CursorPosition { get; set; } = 0; public static bool IsReadingInput { get; set; } = false; + private static int GetCharWidth(char c) + { + if (char.IsControl(c)) return 0; + + int code = c; + + // CJK Unified Ideographs & Extension A + if (code >= 0x4E00 && code <= 0x9FFF) return 2; + if (code >= 0x3400 && code <= 0x4DBF) return 2; + + // Hangul Syllables + if (code >= 0xAC00 && code <= 0xD7AF) return 2; + + // CJK Symbols and Punctuation, Hiragana, Katakana, Bopomofo, Hangul Compatibility Jamo, etc. + if (code >= 0x3000 && code <= 0x32FF) return 2; + if (code >= 0x3300 && code <= 0x33FF) return 2; + + // Fullwidth Forms (0xFF01 to 0xFF60, 0xFFE0 to 0xFFE6) + if (code >= 0xFF01 && code <= 0xFF60) return 2; + if (code >= 0xFFE0 && code <= 0xFFE6) return 2; + + // CJK Compatibility Ideographs + if (code >= 0xF900 && code <= 0xFAFF) return 2; + + // CJK Compatibility Forms + if (code >= 0xFE30 && code <= 0xFE4F) return 2; + + return 1; + } + + private static int GetStringWidth(string s) + { + if (string.IsNullOrEmpty(s)) return 0; + int width = 0; + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (char.IsHighSurrogate(c) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])) + { + int codePoint = char.ConvertToUtf32(c, s[i + 1]); + i++; // skip low surrogate + if (codePoint >= 0x20000 && codePoint <= 0x3FFFF) + { + width += 2; // CJK Extensions B, C, D, E, F, G etc. + } + else + { + width += 1; + } + } + else + { + width += GetCharWidth(c); + } + } + return width; + } + public static void ClearInputLine() { lock (ConsoleLock) { if (!IsReadingInput) return; - int length = 2 + (CurrentInput?.Length ?? 0); + int length = 2 + GetStringWidth(CurrentInput); Console.Write("\r" + new string(' ', length) + "\r"); } } @@ -58,10 +116,11 @@ public static void RedrawInputLine() { if (!IsReadingInput) return; Console.Write("> " + CurrentInput); - int backspaces = (CurrentInput?.Length ?? 0) - CursorPosition; - for (int i = 0; i < backspaces; i++) + if (CurrentInput != null && CursorPosition < CurrentInput.Length) { - Console.Write("\b"); + string remaining = CurrentInput.Substring(CursorPosition); + int remainingWidth = GetStringWidth(remaining); + Console.Write(new string('\b', remainingWidth)); } } } @@ -90,15 +149,19 @@ private void PrintTiledCompletions(List options) Console.WriteLine(); lineLen = 0; } - Console.Write(option.PadRight(colWidth)); + int optionWidth = GetStringWidth(option); + int padding = colWidth - optionWidth; + if (padding < 0) padding = 0; + Console.Write(option + new string(' ', padding)); lineLen += colWidth; } Console.WriteLine(); Console.Write("> " + CurrentInput); - int backspaces = CurrentInput.Length - CursorPosition; - for (int i = 0; i < backspaces; i++) + if (CurrentInput != null && CursorPosition < CurrentInput.Length) { - Console.Write("\b"); + string remaining = CurrentInput.Substring(CursorPosition); + int remainingWidth = GetStringWidth(remaining); + Console.Write(new string('\b', remainingWidth)); } } } @@ -227,19 +290,20 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke lock (ConsoleLock) { cursor--; + char deletedChar = buffer[cursor]; buffer.Remove(cursor, 1); CurrentInput = buffer.ToString(); CursorPosition = cursor; - Console.Write("\b \b"); + int deletedWidth = GetCharWidth(deletedChar); + Console.Write(new string('\b', deletedWidth) + new string(' ', deletedWidth) + new string('\b', deletedWidth)); + if (cursor < buffer.Length) { string remaining = buffer.ToString().Substring(cursor); Console.Write(remaining + " "); - for (int i = 0; i <= remaining.Length; i++) - { - Console.Write("\b"); - } + int remainingWidth = GetStringWidth(remaining); + Console.Write(new string('\b', remainingWidth + 1)); } } } @@ -265,8 +329,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke lock (ConsoleLock) { string completion = completions[0]; - int currentLength = buffer.Length; - for (int i = 0; i < currentLength; i++) + int currentVisualWidth = GetStringWidth(buffer.ToString()); + for (int i = 0; i < currentVisualWidth; i++) { Console.Write("\b \b"); } @@ -299,8 +363,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke { lock (ConsoleLock) { - int currentLength = buffer.Length; - for (int i = 0; i < currentLength; i++) + int currentVisualWidth = GetStringWidth(buffer.ToString()); + for (int i = 0; i < currentVisualWidth; i++) { Console.Write("\b \b"); } @@ -324,8 +388,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke _historyIndex--; lock (ConsoleLock) { - int currentLength = buffer.Length; - for (int i = 0; i < currentLength; i++) + int currentVisualWidth = GetStringWidth(buffer.ToString()); + for (int i = 0; i < currentVisualWidth; i++) { Console.Write("\b \b"); } @@ -349,8 +413,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke _historyIndex++; lock (ConsoleLock) { - int currentLength = buffer.Length; - for (int i = 0; i < currentLength; i++) + int currentVisualWidth = GetStringWidth(buffer.ToString()); + for (int i = 0; i < currentVisualWidth; i++) { Console.Write("\b \b"); } @@ -381,7 +445,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke { cursor--; CursorPosition = cursor; - Console.Write("\b"); + int charWidth = GetCharWidth(buffer[cursor]); + Console.Write(new string('\b', charWidth)); } } } @@ -412,10 +477,8 @@ private async Task ReadLineWithTabCompletionAsync(CancellationToken toke { string remaining = buffer.ToString().Substring(cursor); Console.Write(remaining); - for (int i = 0; i < remaining.Length; i++) - { - Console.Write("\b"); - } + int remainingWidth = GetStringWidth(remaining); + Console.Write(new string('\b', remainingWidth)); } } } From 5603786acd442b4c4e7b8d153e6e4ad4772516e6 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 22 May 2026 10:53:51 +0900 Subject: [PATCH 10/31] Resolving ChatTextBox Rendering and Focus Anomalies We investigated and resolved two rendering/focus issues in the text input area of the ChatDialog under the FNA client. --- Client/MirControls/MirTextBox.cs | 18 ++++++++++++++++++ Client/MirScenes/Dialogs/MainDialogs.cs | 8 ++++++++ Client/Platform/MirInputTypes.cs | 2 +- Cross-platformPortingExperience.md | 9 +++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Client/MirControls/MirTextBox.cs b/Client/MirControls/MirTextBox.cs index 41e2f8212..c9eeea1a2 100644 --- a/Client/MirControls/MirTextBox.cs +++ b/Client/MirControls/MirTextBox.cs @@ -90,6 +90,9 @@ public int MaxLength public event EventHandler GotFocus; public void OnGotFocus() => GotFocus?.Invoke(this, EventArgs.Empty); + + public event EventHandler LostFocus; + public void OnLostFocus() => LostFocus?.Invoke(this, EventArgs.Empty); } private event KeyPressEventHandler KeyPressEvent; @@ -185,6 +188,7 @@ public void LoseFocus() #if FNA Microsoft.Xna.Framework.Input.TextInputEXT.StopTextInput(); #endif + TextBox.OnLostFocus(); } public override void OnKeyDown(KeyEventArgs e) @@ -193,6 +197,20 @@ public override void OnKeyDown(KeyEventArgs e) KeyDownEvent?.Invoke(this, e); if (e.Handled) return; + if (e.KeyCode == Client.Platform.MirKeys.Escape) + { + KeyPressEventArgs args = new KeyPressEventArgs((char)Keys.Escape); + OnKeyPress(args); + if (args.Handled) + { + e.Handled = true; + return; + } + + if (CanLoseFocus) LoseFocus(); + e.Handled = true; + } + if (e.KeyCode == Client.Platform.MirKeys.Tab) { TryTabFocus(); diff --git a/Client/MirScenes/Dialogs/MainDialogs.cs b/Client/MirScenes/Dialogs/MainDialogs.cs index fada23ba7..e385dd44c 100644 --- a/Client/MirScenes/Dialogs/MainDialogs.cs +++ b/Client/MirScenes/Dialogs/MainDialogs.cs @@ -605,6 +605,7 @@ public ChatDialog() ChatTextBox.TextBox.KeyPress += ChatTextBox_KeyPress; ChatTextBox.TextBox.KeyDown += ChatTextBox_KeyDown; ChatTextBox.TextBox.KeyUp += ChatTextBox_KeyUp; + ChatTextBox.TextBox.LostFocus += ChatTextBox_LostFocus; MouseDown += (o, e) => { @@ -768,6 +769,13 @@ private void ChatTextBox_KeyPress(object sender, KeyPressEventArgs e) } } + private void ChatTextBox_LostFocus(object sender, EventArgs e) + { + ChatTextBox.Visible = false; + ChatTextBox.Text = string.Empty; + LinkedItems.Clear(); + } + void PositionBar_OnMoving(object sender, MouseEventArgs e) { int x = Settings.Resolution != 800 ? 619 : 395; diff --git a/Client/Platform/MirInputTypes.cs b/Client/Platform/MirInputTypes.cs index 755215a13..0d8535b23 100644 --- a/Client/Platform/MirInputTypes.cs +++ b/Client/Platform/MirInputTypes.cs @@ -720,7 +720,7 @@ public static void CreateDebugLabel() if (MirControl.MouseControl is MapControl) text += string.Format(", Co Ords: {0}", MapControl.MapLocation); - if (MirControl.MouseControl is MirImageControl) + if (!(MirControl.MouseControl is MapControl)) text += string.Format(", Control: {0}", MirControl.MouseControl.GetType().Name); if (MirScene.ActiveScene is GameScene) diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index f3f2612fc..232d7c191 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -200,6 +200,15 @@ All major porting regressions—specifically map/ground rendering, blend-state v 1. **Virtual Post-Draw Lifecycle Hook:** Changed `AfterDrawControl` from `protected void` to `protected virtual void` in `MirControl.cs` to allow polymorphism and enable custom post-render logic. 2. **Deferred Rendering in GameScene:** In `GameScene.cs`, we excluded the dragged item/gold and screen output text lines from rendering inside `DrawControl()` under the `#if FNA` directive. Instead, we added an override of `AfterDrawControl()` specifically under `#if FNA` to render these overlays at the very end of the control draw cycle—after both `DrawControl()` and `DrawChildControls()` have completed—correctly restoring top-layer rendering. +### 2.36 ChatTextBox Focus and Debug Label Rendering Anomalies +* **The Problem:** + 1. The text input area in the `ChatDialog` remained dark gray after losing focus or when Escape was pressed. Gaining focus for the first time set `Visible = true`, but because the custom FNA textbox implementation was not receiving/processing `Escape` keys in its text input loop, and because clicking outside the textbox only unfocused it without hiding it, the control never transitioned back to `Visible = false` (which would have revealed the clean white backing sprite). + 2. Hovering the mouse over the text input area caused the debug label to drop the control name entirely, changing `Control: ChatDialog, Objects:89` to `Objects:89`. This happened because `MirTextBox` inherits from `MirControl` rather than `MirImageControl`, which failed the debug label's strict `is MirImageControl` type check. +* **The Solution:** + 1. **Escape Key KeyPress Injection:** In `MirTextBox.OnKeyDown` under FNA, we intercepted `Keys.Escape` and manually dispatched a `KeyPress` event with `(char)Keys.Escape` to replicate WinForms behavior. This enables the textbox keypress handler to process the Escape key, hide the textbox, and lose focus. + 2. **LostFocus Event Handler:** We implemented a custom `LostFocus` event signature on `TextBoxStub` and triggered it inside `MirTextBox.LoseFocus()`. In `MainDialogs.cs`, we hooked into `ChatTextBox.TextBox.LostFocus` to set `ChatTextBox.Visible = false`, empty the text, and clear linked items. + 3. **Universal Debug Label Mapping:** We updated `CMain.CreateDebugLabel` in `MirInputTypes.cs` to print the type name of the current control for all hovered controls except `MapControl` (which occupies the entire screen background), restoring proper control tracking on hover. + --- ## 3. Structural Porting Guidelines for Future Reference From 17aaa9203d2bd9832808b17a32cb1e1f31a537e6 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 22 May 2026 18:32:28 +0900 Subject: [PATCH 11/31] Custom Window Scaling Decoupled from Wayland Fractional Scaling We have completed the implementation to allow independent window scaling (forcing 100%, 200%, or other custom scales) under Linux/Wayland, decoupling it from the GNOME system scale of 150%. --- Client/Platform/FNA/FNAEntry.cs | 22 ++++- Client/Platform/FNA/FNARenderer.cs | 131 +++++++++++++++++++++++++---- Client/Platform/FNA/ProgramFNA.cs | 10 +++ Client/Platform/MirInputTypes.cs | 4 +- Client/Settings.cs | 6 ++ Cross-platformPortingExperience.md | 9 ++ 6 files changed, 160 insertions(+), 22 deletions(-) diff --git a/Client/Platform/FNA/FNAEntry.cs b/Client/Platform/FNA/FNAEntry.cs index b1360ecf9..eede74010 100644 --- a/Client/Platform/FNA/FNAEntry.cs +++ b/Client/Platform/FNA/FNAEntry.cs @@ -28,8 +28,8 @@ public FNAEntry() Instance = this; Graphics = new GraphicsDeviceManager(this) { - PreferredBackBufferWidth = Settings.ScreenWidth, - PreferredBackBufferHeight = Settings.ScreenHeight, + PreferredBackBufferWidth = (int)(Settings.ScreenWidth * Settings.WindowScale), + PreferredBackBufferHeight = (int)(Settings.ScreenHeight * Settings.WindowScale), IsFullScreen = Settings.FullScreen, SynchronizeWithVerticalRetrace = true }; @@ -55,7 +55,7 @@ protected override void Initialize() // Load baseline configurations _prevKeyboardState = Keyboard.GetState(); - _prevMouseState = Mouse.GetState(); + _prevMouseState = GetScaledMouseState(); // Set running state CMain.Time = 0; @@ -220,7 +220,7 @@ private void PollMouse() { if (MirScene.ActiveScene == null) return; - var currState = Mouse.GetState(); + var currState = GetScaledMouseState(); CMain.MPoint = new System.Drawing.Point(currState.X, currState.Y); // Track Mouse Move @@ -248,6 +248,20 @@ private void PollMouse() _prevMouseState = currState; } + private MouseState GetScaledMouseState() + { + var state = Mouse.GetState(); + if (GraphicsDevice == null) return state; + int dw = GraphicsDevice.PresentationParameters.BackBufferWidth; + int dh = GraphicsDevice.PresentationParameters.BackBufferHeight; + if (dw == Settings.ScreenWidth && dh == Settings.ScreenHeight) return state; + + int x = dw > 0 ? (int)Math.Round(state.X * (double)Settings.ScreenWidth / dw) : state.X; + int y = dh > 0 ? (int)Math.Round(state.Y * (double)Settings.ScreenHeight / dh) : state.Y; + + return new MouseState(x, y, state.ScrollWheelValue, state.LeftButton, state.MiddleButton, state.RightButton, state.XButton1, state.XButton2); + } + private void CheckMouseButton(ButtonState curr, ButtonState prev, MirMouseButtons button, MouseState state) { if (curr == ButtonState.Pressed && prev == ButtonState.Released) diff --git a/Client/Platform/FNA/FNARenderer.cs b/Client/Platform/FNA/FNARenderer.cs index b87bab3cb..ef9e9ad00 100644 --- a/Client/Platform/FNA/FNARenderer.cs +++ b/Client/Platform/FNA/FNARenderer.cs @@ -34,6 +34,16 @@ public class FNARenderer : IGraphicsRenderer, IDisposable private readonly BlendState _additiveBlendState; private readonly BlendState _multiplyBlendState; private readonly VertexPositionColor[] _radialLightVertices; + private float _scaleX = 1f; + private float _scaleY = 1f; + + private void UpdateScaleFactors() + { + int dw = Device.PresentationParameters.BackBufferWidth; + int dh = Device.PresentationParameters.BackBufferHeight; + _scaleX = Settings.ScreenWidth > 0 ? (float)dw / Settings.ScreenWidth : 1f; + _scaleY = Settings.ScreenHeight > 0 ? (float)dh / Settings.ScreenHeight : 1f; + } public FNARenderer(GraphicsDevice device) { @@ -43,7 +53,7 @@ public FNARenderer(GraphicsDevice device) BasicEffect = new BasicEffect(Device) { VertexColorEnabled = true, - Projection = Matrix.CreateOrthographicOffCenter(0, device.Viewport.Width, device.Viewport.Height, 0, 0, 1) + Projection = MatrixType.CreateOrthographicOffCenter(0, Settings.ScreenWidth, Settings.ScreenHeight, 0, 0, 1) }; _whiteTexture = new Texture2D(Device, 1, 1); @@ -94,18 +104,35 @@ public void Initialize(int width, int height, bool fullScreen) { LightRenderTarget.Dispose(); } - LightRenderTarget = new RenderTarget2D(Device, width, height, false, SurfaceFormat.Color, DepthFormat.None); + UpdateScaleFactors(); + LightRenderTarget = new RenderTarget2D(Device, Device.PresentationParameters.BackBufferWidth, Device.PresentationParameters.BackBufferHeight, false, SurfaceFormat.Color, DepthFormat.None); } public void BeginDraw() { - if (_hasTransform) + UpdateScaleFactors(); + if (_scaleX != 1f || _scaleY != 1f) { - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + var scaleMatrix = MatrixType.CreateScale(_scaleX, _scaleY, 1f); + if (_hasTransform) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, _transformMatrix * scaleMatrix); + } + else + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, scaleMatrix); + } } else { - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + if (_hasTransform) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, _transformMatrix); + } + else + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + } } } @@ -121,8 +148,24 @@ public void Clear(System.Drawing.Color color) public void SetViewport(int x, int y, int width, int height) { - Device.Viewport = new Viewport(x, y, width, height); - BasicEffect.Projection = Matrix.CreateOrthographicOffCenter(0, width, height, 0, 0, 1); + UpdateScaleFactors(); + int scaledX = (int)Math.Round(x * _scaleX); + int scaledY = (int)Math.Round(y * _scaleY); + int scaledW = (int)Math.Round(width * _scaleX); + int scaledH = (int)Math.Round(height * _scaleY); + + // Guard viewport bounds against device backbuffer limits + int maxW = Device.PresentationParameters.BackBufferWidth; + int maxH = Device.PresentationParameters.BackBufferHeight; + if (scaledX < 0) scaledX = 0; + if (scaledY < 0) scaledY = 0; + if (scaledW + scaledX > maxW) scaledW = maxW - scaledX; + if (scaledH + scaledY > maxH) scaledH = maxH - scaledY; + if (scaledW <= 0) scaledW = 1; + if (scaledH <= 0) scaledH = 1; + + Device.Viewport = new Viewport(scaledX, scaledY, scaledW, scaledH); + BasicEffect.Projection = MatrixType.CreateOrthographicOffCenter(0, width, height, 0, 0, 1); } public void Draw(TextureHandle texture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color) @@ -160,10 +203,22 @@ public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect // Set additive/blended states for special FX SpriteBatch.End(); + UpdateScaleFactors(); + bool hasScale = (_scaleX != 1f || _scaleY != 1f); + var scaleMatrix = hasScale ? MatrixType.CreateScale(_scaleX, _scaleY, 1f) : MatrixType.Identity; + if (_hasTransform) - SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState, null, null, null, null, _transformMatrix); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState, SamplerState.PointClamp, null, null, null, _transformMatrix * scaleMatrix); + } + else if (hasScale) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState, SamplerState.PointClamp, null, null, null, scaleMatrix); + } else - SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, _additiveBlendState, SamplerState.PointClamp, null, null); + } var xnaRect = new Microsoft.Xna.Framework.Rectangle(sourceRect.X, sourceRect.Y, sourceRect.Width, sourceRect.Height); var xnaColor = new Microsoft.Xna.Framework.Color(color.R, color.G, color.B, color.A) * rate * _opacity; @@ -174,9 +229,17 @@ public void DrawBlend(TextureHandle texture, System.Drawing.Rectangle sourceRect SpriteBatch.End(); if (_hasTransform) - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, _transformMatrix * scaleMatrix); + } + else if (hasScale) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, scaleMatrix); + } else - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + } } public void DrawTinted(TextureHandle texture, TextureHandle maskTexture, System.Drawing.Rectangle sourceRect, System.Drawing.Point position, System.Drawing.Color color, System.Drawing.Color tint) @@ -208,7 +271,7 @@ public void RenderGPULights(List lights, System.Drawing.Color da // Update basic effect parameters for clean 2D pixel space rendering BasicEffect.World = Microsoft.Xna.Framework.Matrix.Identity; BasicEffect.View = Microsoft.Xna.Framework.Matrix.Identity; - BasicEffect.Projection = Microsoft.Xna.Framework.Matrix.CreateOrthographicOffCenter(0, LightRenderTarget.Width, LightRenderTarget.Height, 0, -1, 1); + BasicEffect.Projection = Microsoft.Xna.Framework.Matrix.CreateOrthographicOffCenter(0, Settings.ScreenWidth, Settings.ScreenHeight, 0, -1, 1); BasicEffect.TextureEnabled = false; BasicEffect.VertexColorEnabled = true; @@ -235,10 +298,22 @@ public void RenderGPULights(List lights, System.Drawing.Color da SpriteBatch.End(); // Re-bind default drawing state + UpdateScaleFactors(); + bool hasScale = (_scaleX != 1f || _scaleY != 1f); + var scaleMatrix = hasScale ? MatrixType.CreateScale(_scaleX, _scaleY, 1f) : MatrixType.Identity; + if (_hasTransform) - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, _transformMatrix * scaleMatrix); + } + else if (hasScale) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, scaleMatrix); + } else - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + } } private void DrawGPURadialLight(float centerX, float centerY, float radiusX, float radiusY, System.Drawing.Color color) @@ -335,7 +410,21 @@ public void SetTransform(MatrixType matrix) // Re-apply viewport spritebatch if active SpriteBatch.End(); - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, null, null, null, null, _transformMatrix); + UpdateScaleFactors(); + bool hasScale = (_scaleX != 1f || _scaleY != 1f); + var scaleMatrix = hasScale ? MatrixType.CreateScale(_scaleX, _scaleY, 1f) : MatrixType.Identity; + if (_hasTransform) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, _transformMatrix * scaleMatrix); + } + else if (hasScale) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, scaleMatrix); + } + else + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + } } public void ResetTransform() @@ -344,7 +433,17 @@ public void ResetTransform() _hasTransform = false; SpriteBatch.End(); - SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied); + UpdateScaleFactors(); + bool hasScale = (_scaleX != 1f || _scaleY != 1f); + var scaleMatrix = hasScale ? MatrixType.CreateScale(_scaleX, _scaleY, 1f) : MatrixType.Identity; + if (hasScale) + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null, null, scaleMatrix); + } + else + { + SpriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, null, null); + } } public void SetSurface(object surface) diff --git a/Client/Platform/FNA/ProgramFNA.cs b/Client/Platform/FNA/ProgramFNA.cs index 8838bd43e..2ea1d28b9 100644 --- a/Client/Platform/FNA/ProgramFNA.cs +++ b/Client/Platform/FNA/ProgramFNA.cs @@ -30,6 +30,16 @@ public static void Main(string[] args) // Load client configuration (network IP/Port, graphics, sound, etc.) Settings.Load(); + if (Settings.HighDPI) + { + Environment.SetEnvironmentVariable("FNA_GRAPHICS_ENABLE_HIGHDPI", "1"); + } + else + { + Environment.SetEnvironmentVariable("FNA_GRAPHICS_ENABLE_HIGHDPI", "0"); + Environment.SetEnvironmentVariable("SDL_VIDEO_HIGHDPI_DISABLED", "1"); + } + // Run cross-platform headless update check before launching the game shell if (Settings.P_Patcher) { diff --git a/Client/Platform/MirInputTypes.cs b/Client/Platform/MirInputTypes.cs index 0d8535b23..305d0f599 100644 --- a/Client/Platform/MirInputTypes.cs +++ b/Client/Platform/MirInputTypes.cs @@ -619,8 +619,8 @@ public static void SetResolution(int width, int height) if (FNAEntry.Instance != null) { - FNAEntry.Instance.Graphics.PreferredBackBufferWidth = width; - FNAEntry.Instance.Graphics.PreferredBackBufferHeight = height; + FNAEntry.Instance.Graphics.PreferredBackBufferWidth = (int)(width * Settings.WindowScale); + FNAEntry.Instance.Graphics.PreferredBackBufferHeight = (int)(height * Settings.WindowScale); FNAEntry.Instance.Graphics.ApplyChanges(); if (FNAEntry.Instance.Renderer != null) diff --git a/Client/Settings.cs b/Client/Settings.cs index 1fc732c9f..9c122f0e5 100644 --- a/Client/Settings.cs +++ b/Client/Settings.cs @@ -72,6 +72,8 @@ public static bool UseTestConfig public static string FontName = "Arial"; //"MS Sans Serif" public static float FontSize = 8F; public static bool UseMouseCursors = true; + public static bool HighDPI = true; + public static float WindowScale = 1f; public static bool FPSCap = true; public static int MaxFPS = 100; @@ -221,6 +223,8 @@ public static void Load() Resolution = Reader.ReadInt32("Graphics", "Resolution", Resolution); DebugMode = Reader.ReadBoolean("Graphics", "DebugMode", DebugMode); UseMouseCursors = Reader.ReadBoolean("Graphics", "UseMouseCursors", UseMouseCursors); + HighDPI = Reader.ReadBoolean("Graphics", "HighDPI", HighDPI); + WindowScale = Reader.ReadFloat("Graphics", "WindowScale", WindowScale); //Network UseConfig = Reader.ReadBoolean("Network", "UseConfig", UseConfig); @@ -347,6 +351,8 @@ public static void Save() Reader.Write("Graphics", "Resolution", Resolution); Reader.Write("Graphics", "DebugMode", DebugMode); Reader.Write("Graphics", "UseMouseCursors", UseMouseCursors); + Reader.Write("Graphics", "HighDPI", HighDPI); + Reader.Write("Graphics", "WindowScale", WindowScale); //Sound Reader.Write("Sound", "Volume", Volume); diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 232d7c191..7072ed5c0 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -209,6 +209,15 @@ All major porting regressions—specifically map/ground rendering, blend-state v 2. **LostFocus Event Handler:** We implemented a custom `LostFocus` event signature on `TextBoxStub` and triggered it inside `MirTextBox.LoseFocus()`. In `MainDialogs.cs`, we hooked into `ChatTextBox.TextBox.LostFocus` to set `ChatTextBox.Visible = false`, empty the text, and clear linked items. 3. **Universal Debug Label Mapping:** We updated `CMain.CreateDebugLabel` in `MirInputTypes.cs` to print the type name of the current control for all hovered controls except `MapControl` (which occupies the entire screen background), restoring proper control tracking on hover. +### 2.37 Custom Independent Window Scaling & High-DPI Resolution Decoupling +* **The Problem:** On Linux/Wayland desktops configured with system-wide fractional scaling (e.g., GNOME set to 150%), FNA/SDL2 window dimensions were automatically hijacked by the Wayland compositor, locking the game's display size and scale factor. Attempting to force or bypass scaling using standard GDK or SDL environment variables (e.g., `SDL_VIDEO_HIGHDPI_DISABLED=1`) failed to decouple scaling or resulted in blurred rendering and broken mouse coordinates due to mismatched backbuffer mapping. +* **The Solution:** We implemented a custom, independent window scaling and High-DPI resolution decoupling system: + 1. **Configuration Properties:** Added configuration properties `HighDPI` (bool, default `true`) and `WindowScale` (float, default `1.0`) in `Settings.cs` to allow user-defined scaling factor overrides in `Mir2Config.ini`. + 2. **High-DPI Alignment:** Configured `ProgramFNA.cs` to dynamically initialize `FNA_GRAPHICS_ENABLE_HIGHDPI` and `SDL_VIDEO_HIGHDPI_DISABLED` on application launch depending on the `HighDPI` setting. + 3. **Backbuffer & Resolution Scaling:** Scaled startup backbuffer resolution (`FNAEntry.cs`) and runtime resolution changes (`MirInputTypes.cs`) by `Settings.WindowScale` to request a high-resolution canvas matching the scaled dimensions. + 4. **Inverse Coordinate Translation:** Implemented inverse scaling on polled mouse coordinates in `FNAEntry.cs` (`GetScaledMouseState`) to translate screen-space inputs back into the game's logical width/height bounds, maintaining precise click targets. + 5. **GPU-Accelerated Point Filtering:** Calculated scaling factors dynamically in `FNARenderer.cs` (`UpdateScaleFactors`) and applied them as scaling matrices to all `SpriteBatch.Begin` draw passes. To prevent bilinear blurring at higher magnifications (e.g., 200% scale), we passed `SamplerState.PointClamp` to the `SpriteBatch` pipeline to enforce crisp, pixel-perfect nearest-neighbor scaling. + --- ## 3. Structural Porting Guidelines for Future Reference From 93dd995dbed08404115841bd9d55fecc07b4d0a9 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sun, 24 May 2026 01:40:53 +0900 Subject: [PATCH 12/31] Modified MirGameShopCell.cs to assign the Quantity value to uantity.Text inside the UpdateText() ethod. --- Client/MirControls/MirGameShopCell.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Client/MirControls/MirGameShopCell.cs b/Client/MirControls/MirGameShopCell.cs index 7222fe0bd..3b923f6ac 100644 --- a/Client/MirControls/MirGameShopCell.cs +++ b/Client/MirControls/MirGameShopCell.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; using Client.MirNetwork; using Client.MirScenes; using Client.MirSounds; @@ -274,6 +274,8 @@ public void UpdateText() if (Item.Stock == 0) stockLabel.Text = "∞"; else stockLabel.Text = Item.Stock.ToString(); countLabel.Text = Item.Count.ToString(); + if (quantity != null) + quantity.Text = Quantity.ToString(); if (Item.Info.Type == ItemType.Mount || Item.Info.Type == ItemType.Weapon || Item.Info.Type == ItemType.Armour || Item.Info.Type == ItemType.Transform) { From ce19d48cb35ad353aaf8b3ca85696046410c41ad Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sun, 24 May 2026 03:01:18 +0900 Subject: [PATCH 13/31] Try to isolate the FNA client for legacy code. --- Client/Client.csproj | 16 +++++-------- Client/Forms/CMain.cs | 34 ++++++++++++---------------- Client/MirGraphics/DXManager.cs | 2 ++ Client/MirGraphics/ParticleEngine.cs | 8 +++++++ Client/Platform/GlobalUsings.cs | 3 +-- Server.MirForms/Server.csproj | 4 ++-- Server/Server.Library.csproj | 2 +- Shared/Shared.csproj | 2 +- 8 files changed, 35 insertions(+), 36 deletions(-) diff --git a/Client/Client.csproj b/Client/Client.csproj index 936cf7743..6ffad1410 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -2,7 +2,7 @@ WinExe - net10.0-windows;net10.0 + net8.0-windows7.0;net10.0 disable enable MIR2.ICO @@ -14,7 +14,7 @@ - + true WINDOWS;SLIMDX false @@ -48,20 +48,15 @@ - - - - - - + @@ -71,7 +66,8 @@ - + + @@ -92,7 +88,7 @@ - + diff --git a/Client/Forms/CMain.cs b/Client/Forms/CMain.cs index a76b0c80f..a5a08670f 100644 --- a/Client/Forms/CMain.cs +++ b/Client/Forms/CMain.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics; using System.Drawing.Drawing2D; using System.Drawing.Imaging; @@ -13,12 +13,6 @@ using SlimDX.Direct3D9; using SlimDX.Windows; using Font = System.Drawing.Font; -using KeyEventArgs = System.Windows.Forms.KeyEventArgs; -using MouseEventArgs = System.Windows.Forms.MouseEventArgs; -using KeyPressEventArgs = System.Windows.Forms.KeyPressEventArgs; -using Keys = System.Windows.Forms.Keys; -using MouseButtons = System.Windows.Forms.MouseButtons; -using Client.Platform; namespace Client { @@ -130,7 +124,7 @@ private static void Application_Idle(object sender, EventArgs e) private static void CMain_Deactivate(object sender, EventArgs e) { - MapControl.MapButtons = Client.Platform.MirMouseButtons.None; + MapControl.MapButtons = MouseButtons.None; Shift = false; Alt = false; Ctrl = false; @@ -166,7 +160,7 @@ public static void CMain_KeyDown(object sender, KeyEventArgs e) } if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyDown(e.ToNeutral()); + MirScene.ActiveScene.OnKeyDown(e); } catch (Exception ex) @@ -184,7 +178,7 @@ public static void CMain_MouseMove(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseMove(e.ToNeutral()); + MirScene.ActiveScene.OnMouseMove(e); } catch (Exception ex) { @@ -212,7 +206,7 @@ public static void CMain_KeyUp(object sender, KeyEventArgs e) foreach (KeyBind KeyCheck in CMain.InputKeys.Keylist) { if (KeyCheck.function != KeybindOptions.Screenshot) continue; - if (KeyCheck.Key != (Client.Platform.MirKeys)e.KeyCode) + if (KeyCheck.Key != e.KeyCode) continue; if ((KeyCheck.RequireAlt != 2) && (KeyCheck.RequireAlt != (Alt ? 1 : 0))) continue; @@ -229,7 +223,7 @@ public static void CMain_KeyUp(object sender, KeyEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyUp(e.ToNeutral()); + MirScene.ActiveScene.OnKeyUp(e); } catch (Exception ex) { @@ -241,7 +235,7 @@ public static void CMain_KeyPress(object sender, KeyPressEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnKeyPress(e.ToNeutral()); + MirScene.ActiveScene.OnKeyPress(e); } catch (Exception ex) { @@ -253,7 +247,7 @@ public static void CMain_MouseDoubleClick(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseClick(e.ToNeutral()); + MirScene.ActiveScene.OnMouseClick(e); } catch (Exception ex) { @@ -262,14 +256,14 @@ public static void CMain_MouseDoubleClick(object sender, MouseEventArgs e) } public static void CMain_MouseUp(object sender, MouseEventArgs e) { - MapControl.MapButtons &= ~e.Button.ToNeutral(); + MapControl.MapButtons &= ~e.Button; if (e.Button != MouseButtons.Right || !Settings.NewMove) GameScene.CanRun = false; try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseUp(e.ToNeutral()); + MirScene.ActiveScene.OnMouseUp(e); } catch (Exception ex) { @@ -296,7 +290,7 @@ public static void CMain_MouseDown(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseDown(e.ToNeutral()); + MirScene.ActiveScene.OnMouseDown(e); } catch (Exception ex) { @@ -308,7 +302,7 @@ public static void CMain_MouseClick(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseClick(e.ToNeutral()); + MirScene.ActiveScene.OnMouseClick(e); } catch (Exception ex) { @@ -320,7 +314,7 @@ public static void CMain_MouseWheel(object sender, MouseEventArgs e) try { if (MirScene.ActiveScene != null) - MirScene.ActiveScene.OnMouseWheel(e.ToNeutral()); + MirScene.ActiveScene.OnMouseWheel(e); } catch (Exception ex) { @@ -641,7 +635,7 @@ public static void SaveError(string ex) { if (Settings.RemainingErrorLogs-- > 0) { - File.AppendAllText(Path.Combine(AppContext.BaseDirectory, "Error.txt"), + File.AppendAllText(@".\Error.txt", string.Format("[{0}] {1}{2}", Now, ex, Environment.NewLine)); } } diff --git a/Client/MirGraphics/DXManager.cs b/Client/MirGraphics/DXManager.cs index 4d8a6fb9b..b5b59e3a0 100644 --- a/Client/MirGraphics/DXManager.cs +++ b/Client/MirGraphics/DXManager.cs @@ -18,6 +18,8 @@ class DXManager public static Sprite Sprite; public static Line Line; + public static Client.Platform.IAssetResolver AssetResolver; + public static Surface CurrentSurface; public static Surface MainSurface; public static PresentParameters Parameters; diff --git a/Client/MirGraphics/ParticleEngine.cs b/Client/MirGraphics/ParticleEngine.cs index cd9474289..ffa7ee494 100644 --- a/Client/MirGraphics/ParticleEngine.cs +++ b/Client/MirGraphics/ParticleEngine.cs @@ -139,7 +139,11 @@ public virtual Particle GenerateNewParticle(ParticleType type) BlendRate = 0.1F, AliveTime = DateTime.MaxValue, Blend = true, +#if !FNA + BlendMode = BlendMode.NORMAL +#else BlendMode = Client.MirGraphics.BlendMode.Normal +#endif //BlendRate = (rate / (float)100), }; @@ -155,7 +159,11 @@ public virtual Particle GenerateNewParticle(ParticleType type) BlendRate = 0.1F, AliveTime = DateTime.MaxValue, Blend = true, +#if !FNA + BlendMode = BlendMode.NORMAL +#else BlendMode = Client.MirGraphics.BlendMode.Normal +#endif //BlendRate = (rate / (float)100), }; particles.Add(particle); diff --git a/Client/Platform/GlobalUsings.cs b/Client/Platform/GlobalUsings.cs index ffa319d55..fa9604bfe 100644 --- a/Client/Platform/GlobalUsings.cs +++ b/Client/Platform/GlobalUsings.cs @@ -1,3 +1,4 @@ +#if FNA global using System.Drawing; global using Keys = Client.Platform.MirKeys; global using MouseButtons = Client.Platform.MirMouseButtons; @@ -7,8 +8,6 @@ global using MouseEventHandler = Client.Platform.MirMouseEventHandler; global using KeyEventHandler = Client.Platform.MirKeyEventHandler; global using KeyPressEventHandler = Client.Platform.MirKeyPressEventHandler; - -#if FNA global using Vector2 = Microsoft.Xna.Framework.Vector2; global using Vector3 = Microsoft.Xna.Framework.Vector3; global using Matrix = Microsoft.Xna.Framework.Matrix; diff --git a/Server.MirForms/Server.csproj b/Server.MirForms/Server.csproj index 939b7e78c..180b77889 100644 --- a/Server.MirForms/Server.csproj +++ b/Server.MirForms/Server.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -29,7 +29,7 @@ - + diff --git a/Server/Server.Library.csproj b/Server/Server.Library.csproj index 4e1741e65..7038a856a 100644 --- a/Server/Server.Library.csproj +++ b/Server/Server.Library.csproj @@ -2,7 +2,7 @@ Library - net10.0 + net10.0;net8.0 disable enable diff --git a/Shared/Shared.csproj b/Shared/Shared.csproj index 7849a1220..6a7607e79 100644 --- a/Shared/Shared.csproj +++ b/Shared/Shared.csproj @@ -1,7 +1,7 @@ - net10.0 + net8.0;net10.0 enable disable From 096984034385161c2a7b937975a5fde818002688 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sun, 24 May 2026 22:57:12 +0900 Subject: [PATCH 14/31] Headless Patcher File Cleaning Bug Fix Walkthrough I have successfully fixed the file deletion issue during the automatic update process of the Linux FNA client. --- Client/Platform/FNA/ProgramFNA.cs | 4 +++- Client/Utils/HeadlessPatcher.cs | 22 ++++++++++++++++------ Cross-platformPortingExperience.md | 2 ++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Client/Platform/FNA/ProgramFNA.cs b/Client/Platform/FNA/ProgramFNA.cs index 2ea1d28b9..5afde9d54 100644 --- a/Client/Platform/FNA/ProgramFNA.cs +++ b/Client/Platform/FNA/ProgramFNA.cs @@ -10,11 +10,13 @@ public static void Main(string[] args) { // Parse command-line flags + bool cleanFiles = false; if (args.Length > 0) { foreach (var arg in args) { if (arg.ToLower() == "-tc") Settings.UseTestConfig = true; + if (arg.ToLower() == "-clean" || arg.ToLower() == "-cleanfiles" || arg.ToLower() == "--clean-files") cleanFiles = true; } } @@ -49,7 +51,7 @@ public static void Main(string[] args) var patcher = new Launcher.HeadlessPatcher(); // Synchronously run the headless patcher to completion - bool patchSuccess = patcher.RunAsync().GetAwaiter().GetResult(); + bool patchSuccess = patcher.RunAsync(cleanFiles).GetAwaiter().GetResult(); if (!patchSuccess) { Console.WriteLine("[Launcher] Headless patching flow failed. Aborting game execution."); diff --git a/Client/Utils/HeadlessPatcher.cs b/Client/Utils/HeadlessPatcher.cs index af94b568c..896021459 100644 --- a/Client/Utils/HeadlessPatcher.cs +++ b/Client/Utils/HeadlessPatcher.cs @@ -61,7 +61,7 @@ static HeadlessPatcher() /// /// Run the full headless patching flow asynchronously. /// - public async Task RunAsync(CancellationToken cancellationToken = default) + public async Task RunAsync(bool cleanFiles = false, CancellationToken cancellationToken = default) { // Step 1: Self-Update Check if (CheckSelfUpdate()) @@ -100,7 +100,7 @@ public async Task RunAsync(CancellationToken cancellationToken = default) LogProgress(null, 0, 0, 0, 0, 0, "Game client is up-to-date!"); // Clean obsolete files even if no downloads are needed - if (Settings.P_Patcher) // Mimic AMain clean behavior + if (cleanFiles) { CleanUpObsoleteFiles(manifestList); } @@ -167,7 +167,10 @@ await Parallel.ForEachAsync(downloadQueue, options, async (fileInfo, ct) => } // Step 7: Clean Up Obsolete Files - CleanUpObsoleteFiles(manifestList); + if (cleanFiles) + { + CleanUpObsoleteFiles(manifestList); + } LogProgress(null, _totalFilesToDownload, _totalFilesToDownload, _totalBytesToDownload, _totalBytesToDownload, 0, "Update completed successfully! Client is up to date."); @@ -506,13 +509,18 @@ public void CleanUpObsoleteFiles(List manifestList) foreach (var filePath in filePaths) { string relativePath = Path.GetRelativePath(clientDir, filePath); + string normalizedPath = relativePath.Replace('\\', '/'); // Keep Screenshots - if (relativePath.StartsWith("Screenshots", StringComparison.OrdinalIgnoreCase)) + if (normalizedPath.StartsWith("Screenshots", StringComparison.OrdinalIgnoreCase)) continue; // Keep Temp Patch Folder - if (relativePath.StartsWith(".patch_temp", StringComparison.OrdinalIgnoreCase)) + if (normalizedPath.StartsWith(".patch_temp", StringComparison.OrdinalIgnoreCase)) + continue; + + // Keep User Data + if (normalizedPath.StartsWith("Data/UserData", StringComparison.OrdinalIgnoreCase)) continue; string fileName = Path.GetFileName(filePath); @@ -523,10 +531,12 @@ public void CleanUpObsoleteFiles(List manifestList) fileName.Equals("Mir2Test.ini", StringComparison.OrdinalIgnoreCase) || fileName.Equals("Mir2Config.ini.patch_old", StringComparison.OrdinalIgnoreCase) || fileName.Equals("Mir2Test.ini.patch_old", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("KeyBinds.ini", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Error.txt", StringComparison.OrdinalIgnoreCase) || fileName.Equals(Path.GetFileName(Environment.ProcessPath), StringComparison.OrdinalIgnoreCase) || filePath.EndsWith(".patch_old", StringComparison.OrdinalIgnoreCase) || extension == ".dll" || extension == ".so" || extension == ".pdb" || - extension == ".json" || extension == ".config" || + extension == ".json" || extension == ".config" || extension == ".ico" || filePath.Contains(".so.") || fileName.Equals("Client", StringComparison.OrdinalIgnoreCase)) continue; diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 7072ed5c0..39061aca2 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -91,12 +91,14 @@ All major porting regressions—specifically map/ground rendering, blend-state v 2. **File Path Separator Mismatches:** On Linux filesystems, relative paths starting with `.\` (e.g., `new InIReader(@".\Mir2Config.ini")`) were parsed literally as starting with a dot and a backslash character, failing to locate the configuration files. 3. **Process Locking & Atomic Swapping:** Overwriting active assemblies (like `Client.dll` or `AutoPatcher.exe`) would trigger write-access violations while the application process was running. 4. **Synchronization Context Deadlocks:** Blocking synchronous entry-point calls (`.GetResult()`) on async patch tasks without `.ConfigureAwait(false)` caused thread-scheduling deadlocks, halting the application early with `Progress: 0/0 files`. + 5. **Destructive Auto-Cleaning During Updates:** The patcher automatically performed obsolete file cleanup (`CleanUpObsoleteFiles`) by default on every update cycle. This deleted local client-specific and user-specific files (e.g., `MIR2.ICO`, `KeyBinds.ini`, and `Data/UserData/QuestTracking.ini`) that were not tracked by the server's update manifest. * **The Solution:** 1. **Decoupled Patcher Engine:** Extracted WinForms UI code and built `HeadlessPatcher.cs` using high-performance, zero-allocation GZip streaming (`ArrayPool.Shared`). 2. **Separator & File Normalization:** Configured cross-platform relative path lookups using `AppContext.BaseDirectory` combined with forward slashes for CDN request URLs. 3. **Atomic Swapping & Inode Unlinking:** Implemented a rename-first assembly replacement strategy (moving locked binaries to `.patch_old`). On Linux, inode unlinking allowed immediate deletion, while Windows cleanups were deferred to next startup. 4. **Deadlock Resolution & Throttled Console:** Applied `.ConfigureAwait(false)` to all async await calls to bypass calling synchronization contexts. Integrated thread-safe 500ms throttled console reporting to prevent terminal spam while displaying live download progress. 5. **Auto-Resume Capabilities:** Updated the download pipeline to skip files that already exist in `.patch_temp` with verified sizes, enabling automatic download resumption upon client restarts. + 6. **Conditional Cleaning & Exclude Protection:** Restricted obsolete file cleaning to only execute when explicitly requested via command-line flags (`-clean`, `-cleanfiles`, or `--clean-files`) parsed at startup. Furthermore, refactored `CleanUpObsoleteFiles` to normalize relative paths and explicitly safeguard crucial directories and files—such as `Data/UserData/`, `KeyBinds.ini`, `Error.txt`, and `.ico` files—from being deleted even during manual cleanup operations. ### 2.15 Nested Project Directory Globbing & TargetFrameworkAttribute Duplication * **The Problem:** When running the compilation command `dotnet build Client/Client.csproj -f net10.0 -c Release`, the build failed with `error CS0579: Duplicate 'global::System.Runtime.Versioning.TargetFrameworkAttribute' attribute`. This occurred because the `Platform/MonoGameCompat` project is nested under the `Client` directory structure. By default, MSBuild globbing (`**/*.cs`) compiled the source files and dynamic `obj/` assembly attributes of the nested project directly into the parent `Client` assembly, while also referencing it as a separate project reference, causing duplicate compilation and attribute declarations. From 602ad73410a17c5996a8724e102ad9604cfe613d Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 25 May 2026 01:04:46 +0900 Subject: [PATCH 15/31] We resolved the compiler warnings in the Client and Shared projects --- Client/MirGraphics/Particles/Particle.cs | 41 +++++++++------------- Client/MirObjects/MonsterObject.cs | 10 +++--- Client/MirScenes/Dialogs/ItemRentDialog.cs | 5 +-- Client/MirScenes/Dialogs/NPCDialogs.cs | 6 ---- Shared/Packet.cs | 4 +-- 5 files changed, 26 insertions(+), 40 deletions(-) diff --git a/Client/MirGraphics/Particles/Particle.cs b/Client/MirGraphics/Particles/Particle.cs index be5e116f8..50daede47 100644 --- a/Client/MirGraphics/Particles/Particle.cs +++ b/Client/MirGraphics/Particles/Particle.cs @@ -132,30 +132,23 @@ public void ProcessImage() } protected virtual void OnPositionChanged() { - try - { - if (ImageInfo.Size.Height == 0 || ImageInfo.Size.Width == 0) - return; - - int xwidth = (int)(ImageInfo.Size.Width * (Math.Ceiling(Settings.ScreenWidth / (decimal)ImageInfo.Size.Width) + 2)); - int ywidth = (int)(ImageInfo.Size.Height * (Math.Ceiling(Settings.ScreenHeight / (decimal)ImageInfo.Size.Height) + 2)); - Vector2 xreset = new Vector2(xwidth, 0); - Vector2 yreset = new Vector2(0, ywidth); - - - if (Position.Y < -ImageInfo.Size.Height * 2) - Position += yreset; - else if (Position.Y > Settings.ScreenHeight + ImageInfo.Size.Height) - Position -= yreset; - else if (Position.X < -ImageInfo.Size.Width * 2) - Position += xreset; - else if (Position.X > Settings.ScreenWidth + ImageInfo.Size.Width) - Position -= xreset; - } - catch (Exception e) - { - throw e; - } + if (ImageInfo.Size.Height == 0 || ImageInfo.Size.Width == 0) + return; + + int xwidth = (int)(ImageInfo.Size.Width * (Math.Ceiling(Settings.ScreenWidth / (decimal)ImageInfo.Size.Width) + 2)); + int ywidth = (int)(ImageInfo.Size.Height * (Math.Ceiling(Settings.ScreenHeight / (decimal)ImageInfo.Size.Height) + 2)); + Vector2 xreset = new Vector2(xwidth, 0); + Vector2 yreset = new Vector2(0, ywidth); + + + if (Position.Y < -ImageInfo.Size.Height * 2) + Position += yreset; + else if (Position.Y > Settings.ScreenHeight + ImageInfo.Size.Height) + Position -= yreset; + else if (Position.X < -ImageInfo.Size.Width * 2) + Position += xreset; + else if (Position.X > Settings.ScreenWidth + ImageInfo.Size.Width) + Position -= xreset; } public virtual void OnParticleEnd() diff --git a/Client/MirObjects/MonsterObject.cs b/Client/MirObjects/MonsterObject.cs index dcdba2329..506ef0ea4 100644 --- a/Client/MirObjects/MonsterObject.cs +++ b/Client/MirObjects/MonsterObject.cs @@ -1,4 +1,4 @@ -using Client.MirGraphics; +using Client.MirGraphics; using Client.MirScenes; using Client.MirSounds; using S = ServerPackets; @@ -49,7 +49,7 @@ public Point ManualLocationOffset public FrameSet Frames = new FrameSet(); public Frame Frame; - public int FrameIndex, FrameInterval, EffectFrameIndex; + public int FrameIndex, FrameInterval; public uint TargetID; public Point TargetPoint; @@ -63,7 +63,6 @@ public Point ManualLocationOffset public Color OldNameColor; - public SpellEffect CurrentEffect; public uint MasterObjectId; @@ -355,7 +354,7 @@ public override void Process() else { DrawFrame = Frame.Start + (Frame.OffSet * (byte)Direction) + FrameIndex; - DrawWingFrame = Frame.EffectStart + (Frame.EffectOffSet * (byte)Direction) + EffectFrameIndex; + DrawWingFrame = Frame.EffectStart + (Frame.EffectOffSet * (byte)Direction); } @@ -903,6 +902,7 @@ public bool SetAction() case MirAction.AttackRange1: PlayRangeSound(); TargetID = (uint)action.Params[0]; + TargetPoint = (Point)action.Params[1]; CurrentActionLevel = (byte)action.Params[4]; switch (BaseImage) { @@ -1005,6 +1005,7 @@ public bool SetAction() case MirAction.AttackRange2: PlaySecondRangeSound(); TargetID = (uint)action.Params[0]; + TargetPoint = (Point)action.Params[1]; CurrentActionLevel = (byte)action.Params[4]; switch (BaseImage) { @@ -1036,6 +1037,7 @@ public bool SetAction() case MirAction.AttackRange3: PlayThirdRangeSound(); TargetID = (uint)action.Params[0]; + TargetPoint = (Point)action.Params[1]; CurrentActionLevel = (byte)action.Params[4]; switch (BaseImage) { diff --git a/Client/MirScenes/Dialogs/ItemRentDialog.cs b/Client/MirScenes/Dialogs/ItemRentDialog.cs index 5e3e7237d..2c5e2b998 100644 --- a/Client/MirScenes/Dialogs/ItemRentDialog.cs +++ b/Client/MirScenes/Dialogs/ItemRentDialog.cs @@ -1,4 +1,4 @@ -using Client.MirControls; +using Client.MirControls; using Client.MirGraphics; using Client.MirNetwork; using Client.MirSounds; @@ -195,7 +195,6 @@ public sealed class GuestItemRentDialog : MirImageControl private readonly MirButton _lockButton, _rentalPriceButton; private string _guestName; private uint _guestGold; - private bool _guestGoldLocked; public GuestItemRentDialog() { @@ -275,7 +274,6 @@ public void Reset() public void Lock() { _lockButton.Index = 253; - _guestGoldLocked = true; RefreshInterface(); } @@ -283,7 +281,6 @@ public void Lock() private void Unlock() { _lockButton.Index = 250; - _guestGoldLocked = false; } } } diff --git a/Client/MirScenes/Dialogs/NPCDialogs.cs b/Client/MirScenes/Dialogs/NPCDialogs.cs index 636c7beb2..1e6c64b58 100644 --- a/Client/MirScenes/Dialogs/NPCDialogs.cs +++ b/Client/MirScenes/Dialogs/NPCDialogs.cs @@ -2817,7 +2817,6 @@ public sealed class StorageDialog : MirImageControl public MirLabel RentalLabel, StoragePasswordLabel; private bool _storageUnlocked; private bool _pendingOpenAfterPasswordSet; - private bool _forcingPasswordSetup; public StorageDialog() { @@ -3068,7 +3067,6 @@ public void HandleStoragePasswordResult(S.StoragePasswordResult p) if (p.Removing) { _pendingOpenAfterPasswordSet = false; - _forcingPasswordSetup = false; SendStorageSystemMessage(GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.StoragePasswordRemoveSuccess)); _storageUnlocked = false; Hide(); @@ -3081,14 +3079,12 @@ public void HandleStoragePasswordResult(S.StoragePasswordResult p) if (_pendingOpenAfterPasswordSet) { _pendingOpenAfterPasswordSet = false; - _forcingPasswordSetup = false; Show(); } return; } _pendingOpenAfterPasswordSet = false; - _forcingPasswordSetup = false; } private void SendStorageSystemMessage(string message) @@ -3122,7 +3118,6 @@ private void ManageStoragePassword() private void ForceStoragePasswordSetup() { - _forcingPasswordSetup = true; _pendingOpenAfterPasswordSet = true; BeginSetStoragePassword(() => CancelStoragePasswordSetup(), true); } @@ -3130,7 +3125,6 @@ private void ForceStoragePasswordSetup() private void CancelStoragePasswordSetup() { _pendingOpenAfterPasswordSet = false; - _forcingPasswordSetup = false; Hide(); } diff --git a/Shared/Packet.cs b/Shared/Packet.cs index 11f1aeec1..4d91defd9 100644 --- a/Shared/Packet.cs +++ b/Shared/Packet.cs @@ -1,4 +1,4 @@ -using C = ClientPackets; +using C = ClientPackets; using S = ServerPackets; public abstract class Packet @@ -39,7 +39,7 @@ public static Packet ReceivePacket(byte[] rawBytes, out byte[] extra) p.ReadPacket(reader); } - catch (Exception e) + catch (Exception) { throw new InvalidDataException(); } From 7b2d7021fb1af1a0dc60de7eb7b0321d78980a84 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 25 May 2026 01:27:07 +0900 Subject: [PATCH 16/31] Fixing Stuck Mouse Movement on Item Drops --- Client/MirScenes/GameScene.cs | 5 ++++- Client/Platform/FNA/FNAEntry.cs | 16 ++++++++++++++++ Cross-platformPortingExperience.md | 26 ++++++++++++++------------ 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 927e351ad..5b0542ede 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -11809,7 +11809,10 @@ private static void OnMouseUp(object sender, MouseEventArgs e) private static void OnMouseDown(object sender, MouseEventArgs e) { - MapButtons |= e.Button; + if (e.Button != MouseButtons.Left || (GameScene.SelectedCell == null && !GameScene.PickedUpGold)) + { + MapButtons |= e.Button; + } if (e.Button != MouseButtons.Right || !Settings.NewMove) GameScene.CanRun = false; diff --git a/Client/Platform/FNA/FNAEntry.cs b/Client/Platform/FNA/FNAEntry.cs index eede74010..0e4bb9f3f 100644 --- a/Client/Platform/FNA/FNAEntry.cs +++ b/Client/Platform/FNA/FNAEntry.cs @@ -245,6 +245,22 @@ private void PollMouse() CheckMouseButton(currState.RightButton, _prevMouseState.RightButton, MirMouseButtons.Right, currState); CheckMouseButton(currState.MiddleButton, _prevMouseState.MiddleButton, MirMouseButtons.Middle, currState); + if (MirScene.ActiveScene is GameScene) + { + if (currState.LeftButton == ButtonState.Released && MapControl.MapButtons.HasFlag(MirMouseButtons.Left)) + { + MapControl.MapButtons &= ~MirMouseButtons.Left; + } + if (currState.RightButton == ButtonState.Released && MapControl.MapButtons.HasFlag(MirMouseButtons.Right)) + { + MapControl.MapButtons &= ~MirMouseButtons.Right; + } + if (currState.MiddleButton == ButtonState.Released && MapControl.MapButtons.HasFlag(MirMouseButtons.Middle)) + { + MapControl.MapButtons &= ~MirMouseButtons.Middle; + } + } + _prevMouseState = currState; } diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 39061aca2..085437afb 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -37,18 +37,20 @@ All major porting regressions—specifically map/ground rendering, blend-state v * **The Solution:** We updated `MirTextBox.cs`'s focus synchronization. In the FNA pipeline, global keyboard polling bypasses classic WinForms focus chains. By explicitly calling `Activate()` and `Deactivate()` inside the textbox's `SetFocus()` and `LoseFocus()` overrides, the UI manager correctly knows when keyboard inputs must be consumed by the focused chat controls rather than bubbling up to the game scene. ### 2.5 Infinite Mouse Movement & Stuck Buttons -* **The Problem:** Upon entering the game, the player character would run continuously towards the mouse pointer. Left clicks were ignored, and right clicks stopped the movement but left clicks on the floor wouldn't resume regular walk cycles. -* **The Solution:** Under Windows, global mouse tracking was hooked into the host `Form` via `CMain_MouseUp`, which cleared the game's static tracking state (`MapControl.MapButtons &= ~e.Button`). Because the FNA build operates headlessly without the `CMain` form window events, `MapButtons` was never cleared on mouse release. Once clicked, a mouse button state stayed `Pressed` indefinitely. -* **The Solution:** We directly registered a `MouseUp` handler in `MapControl` inside `GameScene.cs`: - ```csharp - private static void OnMouseUp(object sender, MouseEventArgs e) - { - MapButtons &= ~e.Button; - if (e.Button != MouseButtons.Right || !Settings.NewMove) - GameScene.CanRun = false; - } - ``` - This immediately intercept releasing clicks on the map surface, restoring fully responsive, click-to-move, and click-and-hold running mechanics. +* **The Problem:** Upon entering the game, the player character would run continuously towards the mouse pointer. Left clicks were ignored, and right clicks stopped the movement but left clicks on the floor wouldn't resume regular walk cycles. Additionally, dropping items or gold onto the ground pops up a confirmation/amount dialog box. Because the dialog box steals active focus before a mouse release is captured on the map control, the left mouse button stayed stuck in `MapButtons` indefinitely, causing the player to walk automatically as soon as the dialog was closed. +* **The Solution:** We implemented three complementary solutions: + 1. **Direct MapControl MouseUp Registration:** We registered a `MouseUp` handler in `MapControl` inside `GameScene.cs`: + ```csharp + private static void OnMouseUp(object sender, MouseEventArgs e) + { + MapButtons &= ~e.Button; + if (e.Button != MouseButtons.Right || !Settings.NewMove) + GameScene.CanRun = false; + } + ``` + This captures mouse releases that happen directly on the map surface. + 2. **Confirmation Dialog Intercept:** In `GameScene.cs` under `MapControl.OnMouseDown`, we skip adding the Left mouse button flag to `MapControl.MapButtons` entirely if the user is clicking to drop an item or gold (`GameScene.SelectedCell != null || GameScene.PickedUpGold`), preventing a stuck mouse state from registering prior to the popup showing. + 3. **Physical-State Safety Clearing Fallback:** In `FNAEntry.cs` (`PollMouse`), we added a safety fallback check that queries the physical button states from SDL/FNA. If any physical mouse button is released, we automatically clear its corresponding flag in `MapControl.MapButtons`, ensuring mouse button states remain perfectly in sync with the hardware even when UI transitions bypass normal mouse events. ### 2.6 TrueType Font Point-to-Pixel Scaling * **The Problem:** All text in the game appeared extremely small compared to the legacy Windows client. From f0cf21b4249eb4ea144c39ffaa0163b2e66e7e7d Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 25 May 2026 01:47:25 +0900 Subject: [PATCH 17/31] Fix NPC Dialogue Unresolved Tags I have successfully updated the client and server regex patterns and network action handler to support optional @ prefixes in NPC dialogue links (e.g., ). --- Client/MirScenes/Dialogs/NPCDialogs.cs | 8 ++++++-- Server/MirObjects/NPC/NPCScript.cs | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Client/MirScenes/Dialogs/NPCDialogs.cs b/Client/MirScenes/Dialogs/NPCDialogs.cs index 1e6c64b58..e381d6a8e 100644 --- a/Client/MirScenes/Dialogs/NPCDialogs.cs +++ b/Client/MirScenes/Dialogs/NPCDialogs.cs @@ -15,10 +15,10 @@ namespace Client.MirScenes.Dialogs { public sealed class NPCDialog : MirImageControl { - public static Regex R = new Regex(@"<((.*?)\/(\@.*?))>"); + public static Regex R = new Regex(@"<((.*?)\/(\@?.*?))>"); public static Regex C = new Regex(@"{((.*?)\/(.*?))}"); public static Regex L = new Regex(@"\(((.*?)\/(.*?))\)"); - public static Regex B = new Regex(@"<<((.*?)\/(\@.*?))>>"); + public static Regex B = new Regex(@"<<((.*?)\/(\@?.*?))>>"); // New regex patterns for NPC/Monster/Item linking (using IDX) public static Regex MonsterLink = new Regex(@"\[MONSTER:(?\d+)(\|(?[^\]]+))?\]|<\$MONSTER:(?\d+)>", RegexOptions.IgnoreCase); @@ -296,6 +296,10 @@ private void ButtonClicked(string action) if (CMain.Time <= GameScene.NPCTime) return; GameScene.NPCTime = CMain.Time + 5000; + + if (!string.IsNullOrEmpty(action) && !action.StartsWith("@")) + action = "@" + action; + Network.Enqueue(new C.CallNPC { ObjectID = GameScene.NPCID, Key = $"[{action}]" }); } diff --git a/Server/MirObjects/NPC/NPCScript.cs b/Server/MirObjects/NPC/NPCScript.cs index 63ec137a9..b8913f4aa 100644 --- a/Server/MirObjects/NPC/NPCScript.cs +++ b/Server/MirObjects/NPC/NPCScript.cs @@ -521,7 +521,7 @@ private NPCSegment ParseSegment(NPCPage page, IEnumerable scriptLines) List lines = scriptLines.ToList(); List currentSay = say, currentButtons = buttons; - Regex regex = new Regex(@"<.*?/(\@.*?)>"); + Regex regex = new Regex(@"<.*?/(\@?.*?)>"); for (int i = 0; i < lines.Count; i++) { @@ -569,6 +569,8 @@ private NPCSegment ParseSegment(NPCPage page, IEnumerable scriptLines) { string argu = match.Groups[1].Captures[0].Value; argu = argu.Split('/')[0]; + if (!argu.StartsWith("@")) + argu = "@" + argu; currentButtons.Add(string.Format("[{0}]", argu)); match = match.NextMatch(); From 54943e00665e23a1dfbe943a4db39eba991e3645 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 25 May 2026 01:57:08 +0900 Subject: [PATCH 18/31] Server build command after this modification completes with zero warnings --- Server/Utils/Crypto.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/Utils/Crypto.cs b/Server/Utils/Crypto.cs index f4e224f47..7a221f63e 100644 --- a/Server/Utils/Crypto.cs +++ b/Server/Utils/Crypto.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using System.Text; namespace Server.Utils @@ -19,8 +19,8 @@ public static byte[] GenerateSalt() public static string HashPassword(string password, byte[] salt) { - Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(password, salt, Iterations, HashAlgorithmName.SHA1); - return Encoding.UTF8.GetString(pbkdf2.GetBytes(HashSize)); + byte[] hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithmName.SHA1, HashSize); + return Encoding.UTF8.GetString(hash); } } } From d29f8b1c8a576df26f5470ce0265a0264b78fce2 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 25 May 2026 23:26:09 +0900 Subject: [PATCH 19/31] Fix NPC Dialogue Sub-panels Closing Immediately Resolve the issue where clicking options that open sub-panels (e.g. ) in the NPC dialogue box closes the dialog box immediately instead of displaying the pane. --- Client/MirScenes/GameScene.cs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 5b0542ede..0fcef0f1e 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -4230,8 +4230,6 @@ private void NPCGoods(S.NPCGoods p) NPCPanelType = p.Type; HideAddedStoreStats = p.HideAddedStats; - if (!NPCDialog.Visible) return; - switch (NPCPanelType) { case PanelType.Buy: @@ -4262,8 +4260,6 @@ private void NPCPearlGoods(S.NPCPearlGoods p) NPCRate = p.Rate; NPCPanelType = p.Type; - if (!NPCDialog.Visible) return; - NPCGoodsDialog.UsePearls = true; NPCGoodsDialog.NewGoods(p.List); NPCGoodsDialog.Show(); @@ -4271,21 +4267,18 @@ private void NPCPearlGoods(S.NPCPearlGoods p) private void NPCSell() { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Sell; NPCDropDialog.Show(); } private void NPCRepair(S.NPCRepair p) { NPCRate = p.Rate; - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Repair; NPCDropDialog.Show(); } private void NPCStorage() { - if (NPCDialog.Visible) - StorageDialog.Show(); + StorageDialog.Show(); } private void StorageUnlockResult(S.StorageUnlockResult p) { @@ -4310,7 +4303,6 @@ private void NPCRequestInput(S.NPCRequestInput p) private void NPCSRepair(S.NPCSRepair p) { NPCRate = p.Rate; - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.SpecialRepair; NPCDropDialog.Show(); } @@ -4318,7 +4310,6 @@ private void NPCSRepair(S.NPCSRepair p) private void NPCRefine(S.NPCRefine p) { NPCRate = p.Rate; - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Refine; if (p.Refining) { @@ -4331,20 +4322,17 @@ private void NPCRefine(S.NPCRefine p) private void NPCCheckRefine(S.NPCCheckRefine p) { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.CheckRefine; NPCDropDialog.Show(); } private void NPCCollectRefine(S.NPCCollectRefine p) { - if (!NPCDialog.Visible) return; NPCDialog.Hide(); } private void NPCReplaceWedRing(S.NPCReplaceWedRing p) { - if (!NPCDialog.Visible) return; NPCRate = p.Rate; NPCDropDialog.PType = PanelType.ReplaceWedRing; NPCDropDialog.Show(); @@ -5657,7 +5645,6 @@ private void SendOutputMessage(S.SendOutputMessage p) private void NPCConsign() { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Consign; NPCDropDialog.Show(); } @@ -6383,19 +6370,16 @@ private void NPCAwakening() } private void NPCDisassemble() { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Disassemble; NPCDropDialog.Show(); } private void NPCDowngrade() { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Downgrade; NPCDropDialog.Show(); } private void NPCReset() { - if (!NPCDialog.Visible) return; NPCDropDialog.PType = PanelType.Reset; NPCDropDialog.Show(); } From 3f4f34a4f6331ea2696c02904e595f511aac05ae Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Tue, 26 May 2026 00:41:27 +0900 Subject: [PATCH 20/31] Newbie Guild Recruitment & Lag Fix We implemented the changes from the approved plan to resolve the newbie guild initialization, NPC parameter mapping, and lag issues. Changes Made 1. Newbie Guild Initialization Added an ownerless constructor in Server/MirDatabase/GuildInfo.cs to support creating system-owned guilds. Added logic in Server/MirEnvir/Envir.cs inside LoadGuilds() to check for the existence of Settings.NewbieGuild. If it does not exist, a new instance is created and registered with a unique guild index. 2. Newbie Guild Parameter Mapping in NPC Scripts Modified Server/MirObjects/NPC/NPCSegment.cs: CheckType.InGuild: If the script specifies "NewbieGuild", we map it to Settings.NewbieGuild (the custom configured name, like "NewB"). We also perform case-insensitive checks. ActionType.AddToGuild: If the script instructs "AddToGuild NewbieGuild", we map it to the actual configured guild Settings.NewbieGuild. ActionType.RemoveFromGuild: Updated string comparison with Settings.NewbieGuild to be case-insensitive. 3. Buff Lag Fix Modified ProcessBuffs() in Server/MirObjects/HumanObject.cs to add tracking for the active BuffType.Newbie using a local flag newbie. BuffType.Newbie is now properly updated in the loop and flagged for removal if conditions are no longer met. The AddBuff(BuffType.Newbie, ...) call is now guarded by !newbie so it is not invoked on every single server tick, preventing flooding the client with buff packets and avoiding lag (5 FPS). --- Server/MirDatabase/GuildInfo.cs | 27 +++++++++++++++++++++++++++ Server/MirEnvir/Envir.cs | 21 +++++++++++++++++++++ Server/MirObjects/HumanObject.cs | 8 ++++++-- Server/MirObjects/NPC/NPCSegment.cs | 13 ++++++++++--- 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/Server/MirDatabase/GuildInfo.cs b/Server/MirDatabase/GuildInfo.cs index 64aec8f04..5aa909f0a 100644 --- a/Server/MirDatabase/GuildInfo.cs +++ b/Server/MirDatabase/GuildInfo.cs @@ -78,6 +78,33 @@ public GuildInfo(PlayerObject owner, string name) FlagColour = Color.FromArgb(255, Envir.Random.Next(255), Envir.Random.Next(255), Envir.Random.Next(255)); } + public GuildInfo(string name) + { + Name = name; + + var ownerRank = new GuildRank { Name = "Leader", Options = (GuildRankOptions)255, Index = 0 }; + Ranks.Add(ownerRank); + + NeedSave = true; + + if (Level < Settings.Guild_ExperienceList.Count) + { + MaxExperience = Settings.Guild_ExperienceList[Level]; + } + + if (Name == Settings.NewbieGuild) + { + MemberCap = Settings.NewbieGuildMaxSize; + Level = 21; + } + else if(Level < Settings.Guild_MembercapList.Count) + { + MemberCap = Settings.Guild_MembercapList[Level]; + } + + FlagColour = Color.FromArgb(255, Envir.Random.Next(255), Envir.Random.Next(255), Envir.Random.Next(255)); + } + public GuildInfo(BinaryReader reader) { int customversion = Envir.LoadCustomVersion; diff --git a/Server/MirEnvir/Envir.cs b/Server/MirEnvir/Envir.cs index 8ed6f938d..f1c005bc6 100644 --- a/Server/MirEnvir/Envir.cs +++ b/Server/MirEnvir/Envir.cs @@ -3123,6 +3123,27 @@ public void LoadGuilds() } if (count != GuildCount) GuildCount = count; + + bool newbieGuildExists = false; + for (int i = 0; i < GuildList.Count; i++) + { + if (string.Equals(GuildList[i].Name, Settings.NewbieGuild, StringComparison.OrdinalIgnoreCase)) + { + newbieGuildExists = true; + break; + } + } + + if (!newbieGuildExists) + { + GuildInfo newbieGuildInfo = new GuildInfo(Settings.NewbieGuild) + { + GuildIndex = ++NextGuildID + }; + GuildList.Add(newbieGuildInfo); + new GuildObject(newbieGuildInfo); + GuildCount++; + } } } diff --git a/Server/MirObjects/HumanObject.cs b/Server/MirObjects/HumanObject.cs index 93962660d..e3d8c171e 100644 --- a/Server/MirObjects/HumanObject.cs +++ b/Server/MirObjects/HumanObject.cs @@ -397,7 +397,7 @@ public override void Die() { } protected virtual void ProcessBuffs() { bool refresh = false; - bool clearRing = false, skill = false, gm = false, mentor = false, lover = false; + bool clearRing = false, skill = false, gm = false, mentor = false, lover = false, newbie = false; for (int i = Buffs.Count - 1; i >= 0; i--) { @@ -434,6 +434,10 @@ protected virtual void ProcessBuffs() lover = true; if (Info.Married == 0) buff.FlagForRemoval = true; break; + case BuffType.Newbie: + newbie = true; + if (MyGuild == null || !string.Equals(MyGuild.Name, Settings.NewbieGuild, StringComparison.OrdinalIgnoreCase) || Settings.NewbieGuildBuffEnabled == false) buff.FlagForRemoval = true; + break; } if (buff.NextTime > Envir.Time) continue; @@ -537,7 +541,7 @@ protected virtual void ProcessBuffs() } } - if (MyGuild != null && MyGuild.Name == Settings.NewbieGuild && Settings.NewbieGuildBuffEnabled == true) + if (MyGuild != null && string.Equals(MyGuild.Name, Settings.NewbieGuild, StringComparison.OrdinalIgnoreCase) && Settings.NewbieGuildBuffEnabled == true && !newbie) { AddBuff(BuffType.Newbie, this, 0, new Stats { [Stat.ExpRatePercent] = Settings.NewbieGuildExpBuff }); } diff --git a/Server/MirObjects/NPC/NPCSegment.cs b/Server/MirObjects/NPC/NPCSegment.cs index 7daa5b5fc..40440d0ea 100644 --- a/Server/MirObjects/NPC/NPCSegment.cs +++ b/Server/MirObjects/NPC/NPCSegment.cs @@ -2508,7 +2508,10 @@ public bool Check(PlayerObject player) case CheckType.InGuild: if (param[0].Length > 0) { - failed = player.MyGuild == null || player.MyGuild.Name != param[0]; + string guildName = param[0]; + if (string.Equals(guildName, "NewbieGuild", StringComparison.OrdinalIgnoreCase)) + guildName = Settings.NewbieGuild; + failed = player.MyGuild == null || !string.Equals(player.MyGuild.Name, guildName, StringComparison.OrdinalIgnoreCase); break; } @@ -3944,7 +3947,11 @@ private void Act(IList acts, PlayerObject player) { if (player.MyGuild != null) return; - GuildObject guild = Envir.GetGuild(param[0]); + string guildName = param[0]; + if (string.Equals(guildName, "NewbieGuild", StringComparison.OrdinalIgnoreCase)) + guildName = Settings.NewbieGuild; + + GuildObject guild = Envir.GetGuild(guildName); if (guild == null) return; @@ -3959,7 +3966,7 @@ private void Act(IList acts, PlayerObject player) if (player.MyGuildRank == null) return; - if (player.MyGuild.Name == Settings.NewbieGuild) player.RemoveBuff(BuffType.Newbie); + if (string.Equals(player.MyGuild.Name, Settings.NewbieGuild, StringComparison.OrdinalIgnoreCase)) player.RemoveBuff(BuffType.Newbie); if (player.HasBuff(BuffType.Guild)) player.RemoveBuff(BuffType.Guild); player.MyGuild.DeleteMember(player, player.Name); From e98474dec57c26e3c0bf966828ee9a89db20c448 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 28 May 2026 00:08:02 +0900 Subject: [PATCH 21/31] libFNA3D Check and Compilation We have implemented a system-level check during the compilation of the FNA client (net10.0 on Linux) to check for the presence of libFNA3D. If not found on the system, it will automatically build it from source and copy the binaries to the build and publish output directories. --- Client/Client.csproj | 37 ++++++++++++++++++++++++++++++ Cross-platformPortingExperience.md | 7 ++++++ 2 files changed, 44 insertions(+) diff --git a/Client/Client.csproj b/Client/Client.csproj index 6ffad1410..3b4608b5a 100644 --- a/Client/Client.csproj +++ b/Client/Client.csproj @@ -122,4 +122,41 @@ + + $(MSBuildProjectDirectory)/FNA/lib/FNA3D + $(FNA3DSourceDir)/build + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 085437afb..c7271f0c1 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -222,6 +222,13 @@ All major porting regressions—specifically map/ground rendering, blend-state v 4. **Inverse Coordinate Translation:** Implemented inverse scaling on polled mouse coordinates in `FNAEntry.cs` (`GetScaledMouseState`) to translate screen-space inputs back into the game's logical width/height bounds, maintaining precise click targets. 5. **GPU-Accelerated Point Filtering:** Calculated scaling factors dynamically in `FNARenderer.cs` (`UpdateScaleFactors`) and applied them as scaling matrices to all `SpriteBatch.Begin` draw passes. To prevent bilinear blurring at higher magnifications (e.g., 200% scale), we passed `SamplerState.PointClamp` to the `SpriteBatch` pipeline to enforce crisp, pixel-perfect nearest-neighbor scaling. +### 2.38 Automating libFNA3D Check and On-Demand Source Compilation +* **The Problem:** The FNA client version requires the `libFNA3D.so` library (mapped as `libFNA3D.so.0` in `app.config`) to run correctly. On some target systems, this library is not pre-installed, and compiling or fetching it manually is error-prone. +* **The Solution:** We implemented an automated MSBuild pipeline in `Client.csproj` targeting `net10.0` on Linux: + 1. **System Detection Check:** Before compiling the C# project, an execution task runs a fast, dual-layer system check. It checks the system's dynamic linker cache via `ldconfig` and does a GCC link-loader check (`gcc -lFNA3D -shared -o /dev/null -x c /dev/null`) to detect if `libFNA3D` is available system-wide. + 2. **On-Demand Compilation:** If missing, MSBuild automatically creates a build folder under `Client/FNA/lib/FNA3D/build`, runs `cmake ..`, and compiles `libFNA3D.so` using `make`. + 3. **Output Directory Alignment:** Upon successful compilation, all generated library binaries and symbolic links (`libFNA3D.so*`) are copied to both the build target directory (`$(TargetDir)`) and the publish directory (`$(PublishDir)`) to ensure runtime resolution. Incremental build states are preserved to prevent redundant rebuilds. + --- ## 3. Structural Porting Guidelines for Future Reference From 18465705a321b4153a801b32a99f93c545a83553 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 28 May 2026 00:10:12 +0900 Subject: [PATCH 22/31] Align the output path of the headless server compilation. --- Server.Headless/Server.Headless.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/Server.Headless/Server.Headless.csproj b/Server.Headless/Server.Headless.csproj index f0d23d68d..996094420 100644 --- a/Server.Headless/Server.Headless.csproj +++ b/Server.Headless/Server.Headless.csproj @@ -5,6 +5,7 @@ net10.0 enable disable + ..\Build\Server\ From 70c5826873107617303ce77d0650aab648114948 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 28 May 2026 00:14:10 +0900 Subject: [PATCH 23/31] Redirection of FNA Client Resource Resolution to Current Working Directory We updated the FNA (Linux) version of the client to handle resource, configuration, screenshot, and log files using the current working directory (Directory.GetCurrentDirectory()), rather than using the program's binary execution directory (AppContext.BaseDirectory / AppDomain.CurrentDomain.BaseDirectory). --- Client/KeyBindSettings.cs | 8 ++++++++ Client/Platform/FNA/AssetResolver.cs | 4 ++-- Client/Platform/MirInputTypes.cs | 4 ++-- Client/Settings.cs | 14 +++++++++++++- Client/Utils/HeadlessPatcher.cs | 2 +- Cross-platformPortingExperience.md | 8 ++++++++ 6 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Client/KeyBindSettings.cs b/Client/KeyBindSettings.cs index ed9bf6f7e..db5f96f1a 100644 --- a/Client/KeyBindSettings.cs +++ b/Client/KeyBindSettings.cs @@ -121,7 +121,11 @@ public class KeyBind public class KeyBindSettings { +#if !FNA private static InIReader Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "KeyBinds.ini")); +#else + private static InIReader Reader = new InIReader(Path.Combine(Directory.GetCurrentDirectory(), "KeyBinds.ini")); +#endif public List Keylist = new List(); public List DefaultKeylist = new List(); @@ -130,7 +134,11 @@ public KeyBindSettings() New(Keylist); New(DefaultKeylist); +#if !FNA if (!File.Exists(Path.Combine(AppContext.BaseDirectory, "KeyBinds.ini"))) +#else + if (!File.Exists(Path.Combine(Directory.GetCurrentDirectory(), "KeyBinds.ini"))) +#endif { Save(DefaultKeylist); return; diff --git a/Client/Platform/FNA/AssetResolver.cs b/Client/Platform/FNA/AssetResolver.cs index 04e1f14a1..c022790f5 100644 --- a/Client/Platform/FNA/AssetResolver.cs +++ b/Client/Platform/FNA/AssetResolver.cs @@ -13,14 +13,14 @@ public class AssetResolver : IAssetResolver static AssetResolver() { - _transcodeCacheDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TranscodeCache"); + _transcodeCacheDir = Path.Combine(Directory.GetCurrentDirectory(), "TranscodeCache"); if (!Directory.Exists(_transcodeCacheDir)) { Directory.CreateDirectory(_transcodeCacheDir); } // Build case-insensitive virtual filesystem index - BuildVfsIndex(AppDomain.CurrentDomain.BaseDirectory); + BuildVfsIndex(Directory.GetCurrentDirectory()); } private static void BuildVfsIndex(string rootDir) diff --git a/Client/Platform/MirInputTypes.cs b/Client/Platform/MirInputTypes.cs index 305d0f599..0fdda0003 100644 --- a/Client/Platform/MirInputTypes.cs +++ b/Client/Platform/MirInputTypes.cs @@ -562,7 +562,7 @@ public static void CreateScreenShot() } }); - string path = Path.Combine(AppContext.BaseDirectory, "Screenshots"); + string path = Path.Combine(Directory.GetCurrentDirectory(), "Screenshots"); if (!Directory.Exists(path)) Directory.CreateDirectory(path); @@ -605,7 +605,7 @@ public static void SaveError(string ex) { try { - System.IO.File.AppendAllText(System.IO.Path.Combine(AppContext.BaseDirectory, "Error.txt"), $"{DateTime.Now}: {ex}{Environment.NewLine}"); + System.IO.File.AppendAllText(System.IO.Path.Combine(Directory.GetCurrentDirectory(), "Error.txt"), $"{DateTime.Now}: {ex}{Environment.NewLine}"); } catch { } } diff --git a/Client/Settings.cs b/Client/Settings.cs index 9c122f0e5..4f19175e3 100644 --- a/Client/Settings.cs +++ b/Client/Settings.cs @@ -7,7 +7,11 @@ class Settings public const long CleanDelay = 600000; public static int ScreenWidth = 1024, ScreenHeight = 768; +#if !FNA private static InIReader Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "Mir2Config.ini")); +#else + private static InIReader Reader = new InIReader(Path.Combine(Directory.GetCurrentDirectory(), "Mir2Config.ini")); +#endif private static InIReader QuestTrackingReader = new InIReader(Path.Combine(UserDataPath, "QuestTracking.ini")); private static bool _useTestConfig; @@ -21,7 +25,11 @@ public static bool UseTestConfig { if (value == true) { +#if !FNA Reader = new InIReader(Path.Combine(AppContext.BaseDirectory, "Mir2Test.ini")); +#else + Reader = new InIReader(Path.Combine(Directory.GetCurrentDirectory(), "Mir2Test.ini")); +#endif } _useTestConfig = value; } @@ -201,7 +209,7 @@ public static bool #if !FNA public static string P_Client = Application.StartupPath + "\\"; #else - public static string P_Client = AppContext.BaseDirectory + "/"; + public static string P_Client = Directory.GetCurrentDirectory() + "/"; #endif public static bool P_AutoStart = false; public static int P_Concurrency = 1; @@ -325,7 +333,11 @@ public static void Load() try { +#if !FNA string languageDirectory = Path.Combine(AppContext.BaseDirectory, "Localization"); +#else + string languageDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Localization"); +#endif if (!Directory.Exists(languageDirectory)) { Directory.CreateDirectory(languageDirectory); diff --git a/Client/Utils/HeadlessPatcher.cs b/Client/Utils/HeadlessPatcher.cs index 896021459..9669960ce 100644 --- a/Client/Utils/HeadlessPatcher.cs +++ b/Client/Utils/HeadlessPatcher.cs @@ -409,7 +409,7 @@ private async Task DownloadFileAsync(FileInformation fileInfo, string tempDirPat public static bool CheckSelfUpdate() { string exeDir = AppContext.BaseDirectory; - string fromName = Path.Combine(exeDir, "AutoPatcher.gz"); + string fromName = Path.Combine(Settings.P_Client, "AutoPatcher.gz"); string toName = Environment.ProcessPath ?? Path.Combine(exeDir, "AutoPatcher.exe"); if (!File.Exists(fromName)) return false; diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index c7271f0c1..1dc60c0be 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -229,6 +229,14 @@ All major porting regressions—specifically map/ground rendering, blend-state v 2. **On-Demand Compilation:** If missing, MSBuild automatically creates a build folder under `Client/FNA/lib/FNA3D/build`, runs `cmake ..`, and compiles `libFNA3D.so` using `make`. 3. **Output Directory Alignment:** Upon successful compilation, all generated library binaries and symbolic links (`libFNA3D.so*`) are copied to both the build target directory (`$(TargetDir)`) and the publish directory (`$(PublishDir)`) to ensure runtime resolution. Incremental build states are preserved to prevent redundant rebuilds. +### 2.39 Redirection of Resource Resolution to Working Directory +* **The Problem:** In the FNA version of the client, resource files, config files (`Mir2Config.ini`, `Mir2Test.ini`, `KeyBinds.ini`), localized text datasets, error logs, and screenshots were resolved relative to the program's binary execution directory (`AppContext.BaseDirectory` or `AppDomain.CurrentDomain.BaseDirectory`). If the user executed the client from a different working directory, the program could not find game assets or created config and screenshot directories inside the binary path. +* **The Solution:** We updated path resolution for the FNA build target to use the current working directory (`Directory.GetCurrentDirectory()`): + 1. **VFS and Asset Indexing:** Configured `AssetResolver.cs` to index resources and store transcoded audio cache files in the current working directory. + 2. **Config & Localization Paths:** Modified `Settings.cs` and `KeyBindSettings.cs` to target configuration and localization directories in the current working directory when compiled under FNA. + 3. **Screenshots and Logs:** Updated `MirInputTypes.cs` to save captured screenshots and error logs into the working directory. + 4. **Patcher Self-Update Alignment:** Configured `HeadlessPatcher.cs` to look for the downloaded self-update package (`AutoPatcher.gz`) inside `Settings.P_Client` (which is redirected to the working directory). + --- ## 3. Structural Porting Guidelines for Future Reference From 74af86821f4ccf4f1992df59d25475aa9ce2ea17 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 28 May 2026 02:36:43 +0900 Subject: [PATCH 24/31] Headless Server Case-Insensitive File System Support on Linux I have implemented and validated case-insensitive file system resolution for the headless server. Under Linux, filesystem paths are case-sensitive. The database directory contains files (e.g. maps and drop files) with uppercase characters in their names, while the game server loaded them using lowercase filenames. This caused all maps with mixed-casing to fail to load. --- Cross-platformPortingExperience.md | 10 +- Server/Vfs.cs | 379 +++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 Server/Vfs.cs diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 1dc60c0be..1cfd1bf36 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -237,6 +237,14 @@ All major porting regressions—specifically map/ground rendering, blend-state v 3. **Screenshots and Logs:** Updated `MirInputTypes.cs` to save captured screenshots and error logs into the working directory. 4. **Patcher Self-Update Alignment:** Configured `HeadlessPatcher.cs` to look for the downloaded self-update package (`AutoPatcher.gz`) inside `Settings.P_Client` (which is redirected to the working directory). + +### 2.40 Server Case-Insensitive VFS Resolution (Linux Headless Support) +* **The Problem:** Linux filesystems are case-sensitive, but the game database and assets (`assets/Crystal.Database/Jev`) were designed under Windows, featuring files (like map files `.map` and monster drops `.txt`) with mixed/uppercase casing. Because the server loaded maps and drops using lowercase strings, all mixed-case assets failed to load, producing hundreds of "Failed to Load Map" and drop load errors. +* **The Solution:** We implemented a custom, compile-time VFS redirection layer in the `Server` project: + 1. **Shadowing System.IO:** Created `Vfs.cs` declaring `Server.File` and `Server.Directory` static classes in the `Server` namespace. Since implicit usings are enabled in .NET 10, these classes seamlessly shadow `System.IO.File` and `System.IO.Directory` across the entire codebase without needing to rewrite any files. + 2. **VFS Caching Index:** At startup, the static constructor recursively scans and indexes the current working directory, caching normalized and lowercase paths. + 3. **Idempotency & Dynamic Updates:** Methods (like `Exists`, `OpenRead`, `ReadAllLines`, `ReadAllBytes`, `Create`, `Delete`, `Copy`, `Move`) automatically query and resolve requested paths case-insensitively. Runtime file creations, deletes, or moves dynamically update the in-memory index to preserve consistency. + --- ## 3. Structural Porting Guidelines for Future Reference @@ -249,4 +257,4 @@ For engineers maintaining this cross-platform codebase, follow these rules to ma --- ## 4. Final Verdict -The Legend of Mir Crystal client is now **Linux native and stable**, using modern Vulkan/Vulkan-on-Mesa rendering. Visual layouts are crisp, mouse and keyboard inputs are precise, and graphics blending operates exactly as intended by the game's original art system. The automatic updater is fully headless, supporting case-sensitive Unix mirrors, process swapping, and resilient auto-resumable patching. +The Legend of Mir Crystal client and headless server are now **Linux native and stable**. The client uses modern Vulkan/Vulkan-on-Mesa rendering with crisp visual layouts, precise inputs, and accurate additive blending. The headless server operates seamlessly on case-sensitive Linux filesystems with 100% database and map load correctness. The automatic updater is fully headless, supporting case-sensitive Unix mirrors, process swapping, and resilient auto-resumable patching. diff --git a/Server/Vfs.cs b/Server/Vfs.cs new file mode 100644 index 000000000..1e49fb750 --- /dev/null +++ b/Server/Vfs.cs @@ -0,0 +1,379 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; + +namespace Server +{ + public static class File + { + internal static readonly Dictionary _vfsIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + internal static readonly Dictionary _vfsDirIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + + static File() + { + BuildVfsIndex(System.IO.Directory.GetCurrentDirectory()); + } + + private static void BuildVfsIndex(string rootDir) + { + try + { + if (!System.IO.Directory.Exists(rootDir)) return; + + // Index directories + var dirs = System.IO.Directory.GetDirectories(rootDir, "*", System.IO.SearchOption.AllDirectories); + foreach (var dir in dirs) + { + var normalized = System.IO.Path.GetFullPath(dir).Replace('\\', '/'); + _vfsDirIndex[normalized] = dir; + } + + // Index files + var files = System.IO.Directory.GetFiles(rootDir, "*", System.IO.SearchOption.AllDirectories); + foreach (var file in files) + { + var normalized = System.IO.Path.GetFullPath(file).Replace('\\', '/'); + _vfsIndex[normalized] = file; + } + } + catch (Exception ex) + { + Console.WriteLine($"[VFS] Indexing error: {ex}"); + } + } + + public static string Resolve(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + + lock (_vfsIndex) + { + if (_vfsIndex.TryGetValue(fullPath, out var resolvedPath)) + { + return resolvedPath; + } + } + + if (System.IO.File.Exists(path)) + { + lock (_vfsIndex) + { + _vfsIndex[fullPath] = path; + } + return path; + } + + return path; + } + + public static string ResolveDir(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + + lock (_vfsDirIndex) + { + if (_vfsDirIndex.TryGetValue(fullPath, out var resolvedPath)) + { + return resolvedPath; + } + } + + if (System.IO.Directory.Exists(path)) + { + lock (_vfsDirIndex) + { + _vfsDirIndex[fullPath] = path; + } + return path; + } + + return path; + } + + public static bool Exists(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var resolved = Resolve(path); + return System.IO.File.Exists(resolved); + } + + public static System.IO.FileStream Create(string path) + { + var resolved = Resolve(path); + var fs = System.IO.File.Create(resolved); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + return fs; + } + + public static void Delete(string path) + { + var resolved = Resolve(path); + System.IO.File.Delete(resolved); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex.Remove(fullPath); + var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); + _vfsIndex.Remove(resolvedFullPath); + } + } + + public static void Copy(string sourceFileName, string destFileName) + { + var resolvedSource = Resolve(sourceFileName); + var resolvedDest = Resolve(destFileName); + System.IO.File.Copy(resolvedSource, resolvedDest); + lock (_vfsIndex) + { + var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex[normalizedDest] = resolvedDest; + } + } + + public static void Copy(string sourceFileName, string destFileName, bool overwrite) + { + var resolvedSource = Resolve(sourceFileName); + var resolvedDest = Resolve(destFileName); + System.IO.File.Copy(resolvedSource, resolvedDest, overwrite); + lock (_vfsIndex) + { + var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex[normalizedDest] = resolvedDest; + } + } + + public static void Move(string sourceFileName, string destFileName) + { + var resolvedSource = Resolve(sourceFileName); + var resolvedDest = Resolve(destFileName); + System.IO.File.Move(resolvedSource, resolvedDest); + lock (_vfsIndex) + { + var normalizedSource = System.IO.Path.GetFullPath(sourceFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex.Remove(normalizedSource); + _vfsIndex.Remove(System.IO.Path.GetFullPath(resolvedSource).Replace('\\', '/')); + + var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex[normalizedDest] = resolvedDest; + } + } + + public static void Move(string sourceFileName, string destFileName, bool overwrite) + { + var resolvedSource = Resolve(sourceFileName); + var resolvedDest = Resolve(destFileName); + System.IO.File.Move(resolvedSource, resolvedDest, overwrite); + lock (_vfsIndex) + { + var normalizedSource = System.IO.Path.GetFullPath(sourceFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex.Remove(normalizedSource); + _vfsIndex.Remove(System.IO.Path.GetFullPath(resolvedSource).Replace('\\', '/')); + + var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); + _vfsIndex[normalizedDest] = resolvedDest; + } + } + + public static System.IO.FileStream OpenRead(string path) + { + var resolved = Resolve(path); + return System.IO.File.OpenRead(resolved); + } + + public static byte[] ReadAllBytes(string path) + { + var resolved = Resolve(path); + return System.IO.File.ReadAllBytes(resolved); + } + + public static string[] ReadAllLines(string path) + { + var resolved = Resolve(path); + return System.IO.File.ReadAllLines(resolved); + } + + public static string ReadAllText(string path) + { + var resolved = Resolve(path); + return System.IO.File.ReadAllText(resolved); + } + + public static void WriteAllText(string path, string contents) + { + var resolved = Resolve(path); + System.IO.File.WriteAllText(resolved, contents); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + } + + public static void WriteAllText(string path, string contents, System.Text.Encoding encoding) + { + var resolved = Resolve(path); + System.IO.File.WriteAllText(resolved, contents, encoding); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + } + + public static void WriteAllLines(string path, IEnumerable contents) + { + var resolved = Resolve(path); + System.IO.File.WriteAllLines(resolved, contents); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + } + + public static void WriteAllLines(string path, IEnumerable contents, System.Text.Encoding encoding) + { + var resolved = Resolve(path); + System.IO.File.WriteAllLines(resolved, contents, encoding); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + } + + public static void WriteAllBytes(string path, byte[] bytes) + { + var resolved = Resolve(path); + System.IO.File.WriteAllBytes(resolved, bytes); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (_vfsIndex) + { + _vfsIndex[fullPath] = resolved; + } + } + + public static DateTime GetLastWriteTime(string path) + { + var resolved = Resolve(path); + return System.IO.File.GetLastWriteTime(resolved); + } + + public static System.IO.StreamWriter AppendText(string path) + { + var resolved = Resolve(path); + return System.IO.File.AppendText(resolved); + } + + public static IEnumerable ReadLines(string path) + { + var resolved = Resolve(path); + return System.IO.File.ReadLines(resolved); + } + } + + public static class Directory + { + public static bool Exists(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var resolved = File.ResolveDir(path); + return System.IO.Directory.Exists(resolved); + } + + public static System.IO.DirectoryInfo CreateDirectory(string path) + { + var resolved = File.ResolveDir(path); + var di = System.IO.Directory.CreateDirectory(resolved); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (File._vfsDirIndex) + { + File._vfsDirIndex[fullPath] = resolved; + } + return di; + } + + public static string[] GetFiles(string path) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetFiles(resolved); + } + + public static string[] GetFiles(string path, string searchPattern) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetFiles(resolved, searchPattern); + } + + public static string[] GetFiles(string path, string searchPattern, System.IO.SearchOption searchOption) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetFiles(resolved, searchPattern, searchOption); + } + + public static string[] GetDirectories(string path) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetDirectories(resolved); + } + + public static string[] GetDirectories(string path, string searchPattern) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetDirectories(resolved, searchPattern); + } + + public static string[] GetDirectories(string path, string searchPattern, System.IO.SearchOption searchOption) + { + var resolved = File.ResolveDir(path); + return System.IO.Directory.GetDirectories(resolved, searchPattern, searchOption); + } + + public static void Delete(string path) + { + var resolved = File.ResolveDir(path); + System.IO.Directory.Delete(resolved); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (File._vfsDirIndex) + { + File._vfsDirIndex.Remove(fullPath); + var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); + File._vfsDirIndex.Remove(resolvedFullPath); + } + } + + public static void Delete(string path, bool recursive) + { + var resolved = File.ResolveDir(path); + System.IO.Directory.Delete(resolved, recursive); + var normalizedInput = path.Replace('\\', '/'); + var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); + lock (File._vfsDirIndex) + { + File._vfsDirIndex.Remove(fullPath); + var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); + File._vfsDirIndex.Remove(resolvedFullPath); + } + } + } +} From e5829bb1cb4df97682539619a0a8f0baa932e482 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Thu, 28 May 2026 17:06:40 +0900 Subject: [PATCH 25/31] Unified Linux File System Compatibility (VFS) We have consolidated the isolated Linux file system compatibility layers (VFS, path normalization/backslash replacement, and regex-based case-insensitive pattern matching for filenames) into the Shared project. Both the client and server projects now delegate their file resolution calls to the new unified Shared.Vfs.VfsManager. --- Client/Platform/FNA/AssetResolver.cs | 48 +--- Cross-platformPortingExperience.md | 7 + Server/Vfs.cs | 264 +++----------------- Shared/Vfs/VfsManager.cs | 358 +++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 272 deletions(-) create mode 100644 Shared/Vfs/VfsManager.cs diff --git a/Client/Platform/FNA/AssetResolver.cs b/Client/Platform/FNA/AssetResolver.cs index c022790f5..2d74fcb82 100644 --- a/Client/Platform/FNA/AssetResolver.cs +++ b/Client/Platform/FNA/AssetResolver.cs @@ -1,14 +1,13 @@ using System; using System.IO; using System.Diagnostics; -using System.Collections.Generic; using Client.Platform; +using Shared.Vfs; namespace Client.Platform.FNA { public class AssetResolver : IAssetResolver { - private static readonly Dictionary _vfsIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); private static readonly string _transcodeCacheDir; static AssetResolver() @@ -18,60 +17,26 @@ static AssetResolver() { Directory.CreateDirectory(_transcodeCacheDir); } - - // Build case-insensitive virtual filesystem index - BuildVfsIndex(Directory.GetCurrentDirectory()); - } - - private static void BuildVfsIndex(string rootDir) - { - try - { - var files = Directory.GetFiles(rootDir, "*", SearchOption.AllDirectories); - foreach (var file in files) - { - var normalized = Path.GetFullPath(file).Replace('\\', '/'); - _vfsIndex[normalized] = file; - } - } - catch (Exception ex) - { - Console.WriteLine($"VFS Indexing error: {ex}"); - } } public string Resolve(string path) { - if (string.IsNullOrEmpty(path)) return path; - - var normalizedInput = path.Replace('\\', '/'); - var fullPath = Path.GetFullPath(normalizedInput).Replace('\\', '/'); - if (_vfsIndex.TryGetValue(fullPath, out var resolvedPath)) - { - return resolvedPath; - } - - // Fallback to lowercased search if dynamic files are added at runtime - return path; + return VfsManager.Resolve(path); } public bool Exists(string path) { - if (string.IsNullOrEmpty(path)) return false; - var resolved = Resolve(path); - return File.Exists(resolved); + return VfsManager.FileExists(path); } public byte[] ReadAllBytes(string path) { - var resolved = Resolve(path); - return File.ReadAllBytes(resolved); + return VfsManager.ReadAllBytes(path); } public Stream OpenRead(string path) { - var resolved = Resolve(path); - return File.OpenRead(resolved); + return VfsManager.OpenRead(path); } public string ResolveSound(string path) @@ -104,8 +69,7 @@ private string GetTranscodedOggPath(string wmaPath) if (TryTranscode(wmaPath, oggPath)) { // Register the newly created file in the VFS index - var normalized = Path.GetFullPath(oggPath).Replace('\\', '/'); - _vfsIndex[normalized] = oggPath; + VfsManager.RegisterFile(oggPath, oggPath); return oggPath; } diff --git a/Cross-platformPortingExperience.md b/Cross-platformPortingExperience.md index 1cfd1bf36..075647e40 100644 --- a/Cross-platformPortingExperience.md +++ b/Cross-platformPortingExperience.md @@ -245,6 +245,13 @@ All major porting regressions—specifically map/ground rendering, blend-state v 2. **VFS Caching Index:** At startup, the static constructor recursively scans and indexes the current working directory, caching normalized and lowercase paths. 3. **Idempotency & Dynamic Updates:** Methods (like `Exists`, `OpenRead`, `ReadAllLines`, `ReadAllBytes`, `Create`, `Delete`, `Copy`, `Move`) automatically query and resolve requested paths case-insensitively. Runtime file creations, deletes, or moves dynamically update the in-memory index to preserve consistency. +### 2.41 Consolidating Case-Insensitive VFS & Normalization into Shared +* **The Problem:** The Virtual File System (VFS) implementations for case-insensitive file mapping, path normalization, and backslash replacement were duplicated across the client (`AssetResolver.cs`) and server (`Vfs.cs`) projects. This led to code duplication, divergent resolution rules, and lack of unified regex-based case-insensitive pattern matching for filename searches on Linux (such as `Directory.GetFiles` using wildcards). +* **The Solution:** We consolidated all isolated file system compatibility layers into a unified `Shared` project implementation: + 1. **Unified VfsManager:** Implemented `Shared.Vfs.VfsManager.cs` to index and resolve paths case-insensitively, normalize backslashes to forward slashes, and handle dynamic index registrations (for created, deleted, or moved files and directories). + 2. **Regex Glob Translation:** Added regex-based wildcard pattern matching (`GetFilesMatching` and `GetDirectoriesMatching`). Glob search strings are translated on-the-fly to case-insensitive regular expressions, allowing safe case-insensitive file pattern queries on Linux's case-sensitive filesystem. + 3. **Thin Client/Server Delegates:** Refactored `AssetResolver.cs` on the client and the `Server.File` / `Server.Directory` shadowing classes on the server to act as thin wrappers delegating to the unified `VfsManager`. + --- ## 3. Structural Porting Guidelines for Future Reference diff --git a/Server/Vfs.cs b/Server/Vfs.cs index 1e49fb750..c956771ce 100644 --- a/Server/Vfs.cs +++ b/Server/Vfs.cs @@ -1,119 +1,21 @@ using System; using System.IO; using System.Collections.Generic; -using System.Linq; +using Shared.Vfs; namespace Server { public static class File { - internal static readonly Dictionary _vfsIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); - internal static readonly Dictionary _vfsDirIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); - - static File() - { - BuildVfsIndex(System.IO.Directory.GetCurrentDirectory()); - } - - private static void BuildVfsIndex(string rootDir) - { - try - { - if (!System.IO.Directory.Exists(rootDir)) return; - - // Index directories - var dirs = System.IO.Directory.GetDirectories(rootDir, "*", System.IO.SearchOption.AllDirectories); - foreach (var dir in dirs) - { - var normalized = System.IO.Path.GetFullPath(dir).Replace('\\', '/'); - _vfsDirIndex[normalized] = dir; - } - - // Index files - var files = System.IO.Directory.GetFiles(rootDir, "*", System.IO.SearchOption.AllDirectories); - foreach (var file in files) - { - var normalized = System.IO.Path.GetFullPath(file).Replace('\\', '/'); - _vfsIndex[normalized] = file; - } - } - catch (Exception ex) - { - Console.WriteLine($"[VFS] Indexing error: {ex}"); - } - } - - public static string Resolve(string path) - { - if (string.IsNullOrEmpty(path)) return path; - - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - - lock (_vfsIndex) - { - if (_vfsIndex.TryGetValue(fullPath, out var resolvedPath)) - { - return resolvedPath; - } - } - - if (System.IO.File.Exists(path)) - { - lock (_vfsIndex) - { - _vfsIndex[fullPath] = path; - } - return path; - } - - return path; - } - - public static string ResolveDir(string path) - { - if (string.IsNullOrEmpty(path)) return path; - - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - - lock (_vfsDirIndex) - { - if (_vfsDirIndex.TryGetValue(fullPath, out var resolvedPath)) - { - return resolvedPath; - } - } - - if (System.IO.Directory.Exists(path)) - { - lock (_vfsDirIndex) - { - _vfsDirIndex[fullPath] = path; - } - return path; - } - - return path; - } - - public static bool Exists(string path) - { - if (string.IsNullOrEmpty(path)) return false; - var resolved = Resolve(path); - return System.IO.File.Exists(resolved); - } + public static string Resolve(string path) => VfsManager.Resolve(path); + public static string ResolveDir(string path) => VfsManager.ResolveDir(path); + public static bool Exists(string path) => VfsManager.FileExists(path); public static System.IO.FileStream Create(string path) { var resolved = Resolve(path); var fs = System.IO.File.Create(resolved); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.RegisterFile(path, resolved); return fs; } @@ -121,14 +23,7 @@ public static void Delete(string path) { var resolved = Resolve(path); System.IO.File.Delete(resolved); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex.Remove(fullPath); - var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); - _vfsIndex.Remove(resolvedFullPath); - } + VfsManager.UnregisterFile(path, resolved); } public static void Copy(string sourceFileName, string destFileName) @@ -136,11 +31,7 @@ public static void Copy(string sourceFileName, string destFileName) var resolvedSource = Resolve(sourceFileName); var resolvedDest = Resolve(destFileName); System.IO.File.Copy(resolvedSource, resolvedDest); - lock (_vfsIndex) - { - var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex[normalizedDest] = resolvedDest; - } + VfsManager.RegisterFile(destFileName, resolvedDest); } public static void Copy(string sourceFileName, string destFileName, bool overwrite) @@ -148,11 +39,7 @@ public static void Copy(string sourceFileName, string destFileName, bool overwri var resolvedSource = Resolve(sourceFileName); var resolvedDest = Resolve(destFileName); System.IO.File.Copy(resolvedSource, resolvedDest, overwrite); - lock (_vfsIndex) - { - var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex[normalizedDest] = resolvedDest; - } + VfsManager.RegisterFile(destFileName, resolvedDest); } public static void Move(string sourceFileName, string destFileName) @@ -160,15 +47,8 @@ public static void Move(string sourceFileName, string destFileName) var resolvedSource = Resolve(sourceFileName); var resolvedDest = Resolve(destFileName); System.IO.File.Move(resolvedSource, resolvedDest); - lock (_vfsIndex) - { - var normalizedSource = System.IO.Path.GetFullPath(sourceFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex.Remove(normalizedSource); - _vfsIndex.Remove(System.IO.Path.GetFullPath(resolvedSource).Replace('\\', '/')); - - var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex[normalizedDest] = resolvedDest; - } + VfsManager.UnregisterFile(sourceFileName, resolvedSource); + VfsManager.RegisterFile(destFileName, resolvedDest); } public static void Move(string sourceFileName, string destFileName, bool overwrite) @@ -176,117 +56,68 @@ public static void Move(string sourceFileName, string destFileName, bool overwri var resolvedSource = Resolve(sourceFileName); var resolvedDest = Resolve(destFileName); System.IO.File.Move(resolvedSource, resolvedDest, overwrite); - lock (_vfsIndex) - { - var normalizedSource = System.IO.Path.GetFullPath(sourceFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex.Remove(normalizedSource); - _vfsIndex.Remove(System.IO.Path.GetFullPath(resolvedSource).Replace('\\', '/')); - - var normalizedDest = System.IO.Path.GetFullPath(destFileName.Replace('\\', '/')).Replace('\\', '/'); - _vfsIndex[normalizedDest] = resolvedDest; - } + VfsManager.UnregisterFile(sourceFileName, resolvedSource); + VfsManager.RegisterFile(destFileName, resolvedDest); } public static System.IO.FileStream OpenRead(string path) { - var resolved = Resolve(path); - return System.IO.File.OpenRead(resolved); + return VfsManager.OpenRead(path); } public static byte[] ReadAllBytes(string path) { - var resolved = Resolve(path); - return System.IO.File.ReadAllBytes(resolved); + return VfsManager.ReadAllBytes(path); } public static string[] ReadAllLines(string path) { - var resolved = Resolve(path); - return System.IO.File.ReadAllLines(resolved); + return VfsManager.ReadAllLines(path); } public static string ReadAllText(string path) { - var resolved = Resolve(path); - return System.IO.File.ReadAllText(resolved); + return VfsManager.ReadAllText(path); } public static void WriteAllText(string path, string contents) { - var resolved = Resolve(path); - System.IO.File.WriteAllText(resolved, contents); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.WriteAllText(path, contents); } public static void WriteAllText(string path, string contents, System.Text.Encoding encoding) { - var resolved = Resolve(path); - System.IO.File.WriteAllText(resolved, contents, encoding); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.WriteAllText(path, contents, encoding); } public static void WriteAllLines(string path, IEnumerable contents) { - var resolved = Resolve(path); - System.IO.File.WriteAllLines(resolved, contents); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.WriteAllLines(path, contents); } public static void WriteAllLines(string path, IEnumerable contents, System.Text.Encoding encoding) { - var resolved = Resolve(path); - System.IO.File.WriteAllLines(resolved, contents, encoding); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.WriteAllLines(path, contents, encoding); } public static void WriteAllBytes(string path, byte[] bytes) { - var resolved = Resolve(path); - System.IO.File.WriteAllBytes(resolved, bytes); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (_vfsIndex) - { - _vfsIndex[fullPath] = resolved; - } + VfsManager.WriteAllBytes(path, bytes); } public static DateTime GetLastWriteTime(string path) { - var resolved = Resolve(path); - return System.IO.File.GetLastWriteTime(resolved); + return VfsManager.GetLastWriteTime(path); } public static System.IO.StreamWriter AppendText(string path) { - var resolved = Resolve(path); - return System.IO.File.AppendText(resolved); + return VfsManager.AppendText(path); } public static IEnumerable ReadLines(string path) { - var resolved = Resolve(path); - return System.IO.File.ReadLines(resolved); + return VfsManager.ReadLines(path); } } @@ -294,86 +125,59 @@ public static class Directory { public static bool Exists(string path) { - if (string.IsNullOrEmpty(path)) return false; - var resolved = File.ResolveDir(path); - return System.IO.Directory.Exists(resolved); + return VfsManager.DirectoryExists(path); } public static System.IO.DirectoryInfo CreateDirectory(string path) { var resolved = File.ResolveDir(path); var di = System.IO.Directory.CreateDirectory(resolved); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (File._vfsDirIndex) - { - File._vfsDirIndex[fullPath] = resolved; - } + VfsManager.RegisterDir(path, resolved); return di; } public static string[] GetFiles(string path) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetFiles(resolved); + return VfsManager.GetFilesMatching(path, "*", SearchOption.TopDirectoryOnly); } public static string[] GetFiles(string path, string searchPattern) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetFiles(resolved, searchPattern); + return VfsManager.GetFilesMatching(path, searchPattern, SearchOption.TopDirectoryOnly); } public static string[] GetFiles(string path, string searchPattern, System.IO.SearchOption searchOption) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetFiles(resolved, searchPattern, searchOption); + return VfsManager.GetFilesMatching(path, searchPattern, searchOption); } public static string[] GetDirectories(string path) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetDirectories(resolved); + return VfsManager.GetDirectoriesMatching(path, "*", SearchOption.TopDirectoryOnly); } public static string[] GetDirectories(string path, string searchPattern) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetDirectories(resolved, searchPattern); + return VfsManager.GetDirectoriesMatching(path, searchPattern, SearchOption.TopDirectoryOnly); } public static string[] GetDirectories(string path, string searchPattern, System.IO.SearchOption searchOption) { - var resolved = File.ResolveDir(path); - return System.IO.Directory.GetDirectories(resolved, searchPattern, searchOption); + return VfsManager.GetDirectoriesMatching(path, searchPattern, searchOption); } public static void Delete(string path) { var resolved = File.ResolveDir(path); System.IO.Directory.Delete(resolved); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (File._vfsDirIndex) - { - File._vfsDirIndex.Remove(fullPath); - var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); - File._vfsDirIndex.Remove(resolvedFullPath); - } + VfsManager.UnregisterDir(path, resolved); } public static void Delete(string path, bool recursive) { var resolved = File.ResolveDir(path); System.IO.Directory.Delete(resolved, recursive); - var normalizedInput = path.Replace('\\', '/'); - var fullPath = System.IO.Path.GetFullPath(normalizedInput).Replace('\\', '/'); - lock (File._vfsDirIndex) - { - File._vfsDirIndex.Remove(fullPath); - var resolvedFullPath = System.IO.Path.GetFullPath(resolved).Replace('\\', '/'); - File._vfsDirIndex.Remove(resolvedFullPath); - } + VfsManager.UnregisterDir(path, resolved); } } } diff --git a/Shared/Vfs/VfsManager.cs b/Shared/Vfs/VfsManager.cs new file mode 100644 index 000000000..a903d750a --- /dev/null +++ b/Shared/Vfs/VfsManager.cs @@ -0,0 +1,358 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Shared.Vfs +{ + public static class VfsManager + { + private static readonly Dictionary _vfsIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _vfsDirIndex = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly object _lock = new object(); + private static bool _initialized = false; + + static VfsManager() + { + Initialize(Directory.GetCurrentDirectory()); + } + + public static void Initialize(string rootDir = null) + { + lock (_lock) + { + if (_initialized) return; + if (string.IsNullOrEmpty(rootDir)) + { + rootDir = Directory.GetCurrentDirectory(); + } + + try + { + if (!Directory.Exists(rootDir)) return; + + // Index directories + var dirs = Directory.GetDirectories(rootDir, "*", SearchOption.AllDirectories); + foreach (var dir in dirs) + { + var normalized = Path.GetFullPath(dir).Replace('\\', '/'); + _vfsDirIndex[normalized] = dir; + } + + // Index files + var files = Directory.GetFiles(rootDir, "*", SearchOption.AllDirectories); + foreach (var file in files) + { + var normalized = Path.GetFullPath(file).Replace('\\', '/'); + _vfsIndex[normalized] = file; + } + + _initialized = true; + } + catch (Exception ex) + { + Console.WriteLine($"[VFS] Indexing error: {ex}"); + } + } + } + + public static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + return path.Replace('\\', '/'); + } + + public static string Resolve(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var normalizedInput = NormalizePath(path); + var fullPath = Path.GetFullPath(normalizedInput).Replace('\\', '/'); + + lock (_lock) + { + if (_vfsIndex.TryGetValue(fullPath, out var resolvedPath)) + { + return resolvedPath; + } + } + + if (File.Exists(path)) + { + lock (_lock) + { + _vfsIndex[fullPath] = path; + } + return path; + } + + return path; + } + + public static string ResolveDir(string path) + { + if (string.IsNullOrEmpty(path)) return path; + + var normalizedInput = NormalizePath(path); + var fullPath = Path.GetFullPath(normalizedInput).Replace('\\', '/'); + + lock (_lock) + { + if (_vfsDirIndex.TryGetValue(fullPath, out var resolvedPath)) + { + return resolvedPath; + } + } + + if (Directory.Exists(path)) + { + lock (_lock) + { + _vfsDirIndex[fullPath] = path; + } + return path; + } + + return path; + } + + public static bool FileExists(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var resolved = Resolve(path); + return File.Exists(resolved); + } + + public static bool DirectoryExists(string path) + { + if (string.IsNullOrEmpty(path)) return false; + var resolved = ResolveDir(path); + return Directory.Exists(resolved); + } + + public static FileStream OpenRead(string path) + { + var resolved = Resolve(path); + return File.OpenRead(resolved); + } + + public static byte[] ReadAllBytes(string path) + { + var resolved = Resolve(path); + return File.ReadAllBytes(resolved); + } + + public static string[] ReadAllLines(string path) + { + var resolved = Resolve(path); + return File.ReadAllLines(resolved); + } + + public static string ReadAllText(string path) + { + var resolved = Resolve(path); + return File.ReadAllText(resolved); + } + + public static void WriteAllText(string path, string contents) + { + var resolved = Resolve(path); + File.WriteAllText(resolved, contents); + RegisterFile(path, resolved); + } + + public static void WriteAllText(string path, string contents, System.Text.Encoding encoding) + { + var resolved = Resolve(path); + File.WriteAllText(resolved, contents, encoding); + RegisterFile(path, resolved); + } + + public static void WriteAllLines(string path, IEnumerable contents) + { + var resolved = Resolve(path); + File.WriteAllLines(resolved, contents); + RegisterFile(path, resolved); + } + + public static void WriteAllLines(string path, IEnumerable contents, System.Text.Encoding encoding) + { + var resolved = Resolve(path); + File.WriteAllLines(resolved, contents, encoding); + RegisterFile(path, resolved); + } + + public static void WriteAllBytes(string path, byte[] bytes) + { + var resolved = Resolve(path); + File.WriteAllBytes(resolved, bytes); + RegisterFile(path, resolved); + } + + public static DateTime GetLastWriteTime(string path) + { + var resolved = Resolve(path); + return File.GetLastWriteTime(resolved); + } + + public static StreamWriter AppendText(string path) + { + var resolved = Resolve(path); + return File.AppendText(resolved); + } + + public static IEnumerable ReadLines(string path) + { + var resolved = Resolve(path); + return File.ReadLines(resolved); + } + + public static void RegisterFile(string path, string resolvedPath) + { + if (string.IsNullOrEmpty(path)) return; + var normalized = Path.GetFullPath(NormalizePath(path)).Replace('\\', '/'); + lock (_lock) + { + _vfsIndex[normalized] = resolvedPath; + } + } + + public static void UnregisterFile(string path, string resolvedPath = null) + { + if (string.IsNullOrEmpty(path)) return; + var normalized = Path.GetFullPath(NormalizePath(path)).Replace('\\', '/'); + lock (_lock) + { + _vfsIndex.Remove(normalized); + if (resolvedPath != null) + { + var resolvedNormalized = Path.GetFullPath(NormalizePath(resolvedPath)).Replace('\\', '/'); + _vfsIndex.Remove(resolvedNormalized); + } + } + } + + public static void RegisterDir(string path, string resolvedPath) + { + if (string.IsNullOrEmpty(path)) return; + var normalized = Path.GetFullPath(NormalizePath(path)).Replace('\\', '/'); + lock (_lock) + { + _vfsDirIndex[normalized] = resolvedPath; + } + } + + public static void UnregisterDir(string path, string resolvedPath = null) + { + if (string.IsNullOrEmpty(path)) return; + var normalized = Path.GetFullPath(NormalizePath(path)).Replace('\\', '/'); + lock (_lock) + { + _vfsDirIndex.Remove(normalized); + if (resolvedPath != null) + { + var resolvedNormalized = Path.GetFullPath(NormalizePath(resolvedPath)).Replace('\\', '/'); + _vfsDirIndex.Remove(resolvedNormalized); + } + } + } + + public static Regex ConvertGlobToRegex(string wildcard, bool ignoreCase = true) + { + if (string.IsNullOrEmpty(wildcard)) + { + return new Regex("^.*$"); + } + string regexPattern = "^" + Regex.Escape(wildcard).Replace(@"\*", ".*").Replace(@"\?", ".") + "$"; + return new Regex(regexPattern, ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); + } + + public static string[] GetFilesMatching(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + var resolvedDir = ResolveDir(path); + if (!Directory.Exists(resolvedDir)) + { + return Array.Empty(); + } + + var regex = ConvertGlobToRegex(searchPattern); + var results = new List(); + var normalizedDirPrefix = Path.GetFullPath(NormalizePath(resolvedDir)).Replace('\\', '/'); + if (!normalizedDirPrefix.EndsWith("/")) + { + normalizedDirPrefix += "/"; + } + + lock (_lock) + { + foreach (var kvp in _vfsIndex) + { + var fileFullPath = kvp.Key; + var physicalPath = kvp.Value; + + if (fileFullPath.StartsWith(normalizedDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + var relativePart = fileFullPath.Substring(normalizedDirPrefix.Length); + + if (searchOption == SearchOption.TopDirectoryOnly && relativePart.Contains('/')) + { + continue; + } + + var fileName = Path.GetFileName(physicalPath); + if (regex.IsMatch(fileName)) + { + results.Add(physicalPath); + } + } + } + } + + return results.ToArray(); + } + + public static string[] GetDirectoriesMatching(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) + { + var resolvedDir = ResolveDir(path); + if (!Directory.Exists(resolvedDir)) + { + return Array.Empty(); + } + + var regex = ConvertGlobToRegex(searchPattern); + var results = new List(); + var normalizedDirPrefix = Path.GetFullPath(NormalizePath(resolvedDir)).Replace('\\', '/'); + if (!normalizedDirPrefix.EndsWith("/")) + { + normalizedDirPrefix += "/"; + } + + lock (_lock) + { + foreach (var kvp in _vfsDirIndex) + { + var dirFullPath = kvp.Key; + var physicalPath = kvp.Value; + + if (dirFullPath.StartsWith(normalizedDirPrefix, StringComparison.OrdinalIgnoreCase)) + { + var relativePart = dirFullPath.Substring(normalizedDirPrefix.Length); + + if (searchOption == SearchOption.TopDirectoryOnly && relativePart.Contains('/')) + { + continue; + } + + var dirName = Path.GetFileName(physicalPath); + if (regex.IsMatch(dirName)) + { + results.Add(physicalPath); + } + } + } + } + + return results.ToArray(); + } + } +} From 834737f85caf39cb1f1e0f06277c502b6d29daf6 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Fri, 29 May 2026 02:09:12 +0900 Subject: [PATCH 26/31] MainDialog HP and MP Text Alignment Fix (FNA Build) We have resolved the unnatural text alignment for the HP and MP values in the MainDialog health/mana orb under the FNA version of the client for both HPMP display modes. --- Client/MirControls/MirLabel.cs | 64 +++++++++++++++---------- Client/MirScenes/Dialogs/MainDialogs.cs | 4 ++ 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/Client/MirControls/MirLabel.cs b/Client/MirControls/MirLabel.cs index 1f29366d2..0888e4230 100644 --- a/Client/MirControls/MirLabel.cs +++ b/Client/MirControls/MirLabel.cs @@ -310,42 +310,56 @@ protected internal override void DrawControl() var font = Client.Platform.FNA.FNAFontManager.GetFont(Font.Size); var xnaForeCol = new Microsoft.Xna.Framework.Color(ForeColour.R, ForeColour.G, ForeColour.B, ForeColour.A) * Opacity; - var pos = new Microsoft.Xna.Framework.Vector2(DisplayLocation.X, DisplayLocation.Y); - string drawText = _wrappedText ?? Text; - var measuredSize = font.MeasureString(drawText); + string[] lines = drawText.Replace("\r\n", "\n").Split('\n'); - if ((DrawFormat & TextFormatFlags.HorizontalCenter) == TextFormatFlags.HorizontalCenter) - { - pos.X += (Size.Width - measuredSize.X) / 2f; - } - else if ((DrawFormat & TextFormatFlags.Right) == TextFormatFlags.Right) - { - pos.X += Size.Width - measuredSize.X; - } + float singleLineHeight = font.MeasureString("A").Y; + float lineSpacing = font.MeasureString("A\nA").Y - singleLineHeight; + if (lineSpacing <= 0) lineSpacing = singleLineHeight; + + float totalHeight = font.MeasureString(drawText).Y; + float startY = DisplayLocation.Y; if ((DrawFormat & TextFormatFlags.VerticalCenter) == TextFormatFlags.VerticalCenter) { - pos.Y += (Size.Height - measuredSize.Y) / 2f; + startY += (Size.Height - totalHeight) / 2f; } else if ((DrawFormat & TextFormatFlags.Bottom) == TextFormatFlags.Bottom) { - pos.Y += Size.Height - measuredSize.Y; + startY += Size.Height - totalHeight; } - if (OutLine) + for (int i = 0; i < lines.Length; i++) { - var xnaOutCol = new Microsoft.Xna.Framework.Color(OutLineColour.R, OutLineColour.G, OutLineColour.B, OutLineColour.A) * Opacity; - - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaOutCol); - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(0, 1), xnaOutCol); - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(2, 1), xnaOutCol); - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 2), xnaOutCol); - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 1), xnaForeCol); - } - else - { - renderer.SpriteBatch.DrawString(font, drawText, pos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaForeCol); + string line = lines[i]; + var lineSize = font.MeasureString(line); + float lineX = DisplayLocation.X; + + if ((DrawFormat & TextFormatFlags.HorizontalCenter) == TextFormatFlags.HorizontalCenter) + { + lineX += (Size.Width - lineSize.X) / 2f; + } + else if ((DrawFormat & TextFormatFlags.Right) == TextFormatFlags.Right) + { + lineX += Size.Width - lineSize.X; + } + + var linePos = new Microsoft.Xna.Framework.Vector2(lineX, startY + i * lineSpacing); + + if (OutLine) + { + var xnaOutCol = new Microsoft.Xna.Framework.Color(OutLineColour.R, OutLineColour.G, OutLineColour.B, OutLineColour.A) * Opacity; + + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaOutCol); + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(0, 1), xnaOutCol); + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(2, 1), xnaOutCol); + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(1, 2), xnaOutCol); + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(1, 1), xnaForeCol); + } + else + { + renderer.SpriteBatch.DrawString(font, line, linePos + new Microsoft.Xna.Framework.Vector2(1, 0), xnaForeCol); + } } CleanTime = CMain.Time + Settings.CleanDelay; diff --git a/Client/MirScenes/Dialogs/MainDialogs.cs b/Client/MirScenes/Dialogs/MainDialogs.cs index e385dd44c..ae31f423a 100644 --- a/Client/MirScenes/Dialogs/MainDialogs.cs +++ b/Client/MirScenes/Dialogs/MainDialogs.cs @@ -436,7 +436,11 @@ public void Process() if (Settings.HPView) { HealthLabel.Text = string.Format("HP {0}/{1}", User.HP, User.Stats[Stat.HP]); +#if FNA + ManaLabel.Text = HPOnly ? "" : string.Format("MP {0}/{1}", User.MP, User.Stats[Stat.MP]); +#else ManaLabel.Text = HPOnly ? "" : string.Format("MP {0}/{1} ", User.MP, User.Stats[Stat.MP]); +#endif TopLabel.Text = string.Empty; BottomLabel.Text = string.Empty; } From a57d65388cb2bbf40dd66a2adbf29af456f86ee6 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sat, 30 May 2026 01:07:33 +0900 Subject: [PATCH 27/31] Implement account command modifications We have implemented modifications to the account CLI command in the headless server console to handle arrays/lists and sensitive password fields appropriately. --- Server.Headless/CommandProcessor.cs | 864 +++++++++++++++++++++++++++- Server.Headless/README.md | 43 +- 2 files changed, 905 insertions(+), 2 deletions(-) diff --git a/Server.Headless/CommandProcessor.cs b/Server.Headless/CommandProcessor.cs index cf2b2d821..dd3d6c7af 100644 --- a/Server.Headless/CommandProcessor.cs +++ b/Server.Headless/CommandProcessor.cs @@ -1,7 +1,10 @@ using System; +using System.Collections; using System.Collections.Generic; +using System.Drawing; using System.IO; using System.Linq; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -24,10 +27,11 @@ public class CommandProcessor private static readonly string[] PrimaryCommands = new[] { "help", "status", "start", "stop", "reboot", "restart", "exit", "quit", - "reload", "say", "broadcast", "list", "kick", "blockedips", "player", "ipban", "ipunban", "gm" + "reload", "say", "broadcast", "list", "kick", "blockedips", "player", "ipban", "ipunban", "gm", "account" }; private static readonly string[] ReloadSubcommands = new[] { "npc", "drops", "line", "all" }; + private static readonly string[] AccountSubcommands = new[] { "show", "get", "set" }; private static readonly string[] ListSubcommands = new[] { "players", "guilds" }; private static readonly string[] BlockedIpsSubcommands = new[] { "list", "clear", "add", "remove" }; private static readonly string[] PlayerSubcommands = new[] @@ -622,6 +626,31 @@ private List GetCompletions(string text, out int prefixLength) } } } + else if (primaryCmd == "account") + { + if (parts.Count == 1 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(AccountSubcommands); + } + else if (parts.Count == 2 && !endsWithSpace) + { + string word = parts[1]; + prefixLength = word.Length; + completions.AddRange(AccountSubcommands.Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + else if (parts.Count == 2 && endsWithSpace) + { + prefixLength = 0; + completions.AddRange(Envir.Main.AccountList.Select(x => x.AccountID)); + } + else if (parts.Count == 3 && !endsWithSpace) + { + string word = parts[2]; + prefixLength = word.Length; + completions.AddRange(Envir.Main.AccountList.Select(x => x.AccountID).Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } } return completions; @@ -727,6 +756,10 @@ private void ExecuteCommand(string line) HandleGMCommand(parts); break; + case "account": + HandleAccountCommand(parts); + break; + default: Print($"Unknown command '{command}'. Type 'help' for a list of commands."); break; @@ -787,6 +820,7 @@ private void ShowHelp() Print(" blockedips | remove > - Manages IP blocklist."); Print(" player [args...] - Manages a specific player. (Type 'player help' for details)"); Print(" gm - Simulates a chat box command or message for with temporary GM privileges."); + Print(" account [args...] - Manages user accounts and attributes. (Type 'account help' for details)"); } private void ShowPlayerHelp() @@ -1400,5 +1434,833 @@ private void EditPlayerStat(CharacterInfo charInfo, string statName, string rawV break; } } + + private void ShowAccountHelp() + { + Print("=== Account Management Commands (OpenWrt uci style) ==="); + Print(" account show [] - Recursively shows attributes. If no path, lists all accounts."); + Print(" account get - Displays the value of a specific field."); + Print(" account set = - Sets the value of a specific field/property."); + Print(""); + Print("Examples:"); + Print(" account set admin.Gold=999999"); + Print(" account set admin.Characters[0].Inventory[0].Luck=9"); + Print(" account set admin.characterName.Inventory[0].Luck=9"); + Print(" account show admin.characterName"); + } + + private void HandleAccountCommand(string[] parts) + { + if (parts.Length < 2) + { + ShowAccountHelp(); + return; + } + + string sub = parts[1].ToLowerInvariant(); + if (sub == "help") + { + ShowAccountHelp(); + return; + } + + if (sub == "show") + { + if (parts.Length < 3) + { + var accounts = Envir.Main.AccountList; + Print($"--- Accounts List ({accounts.Count}) ---"); + foreach (var acc in accounts) + { + Print($" {acc.AccountID} (Characters: {acc.Characters.Count})"); + } + return; + } + + string path = parts[2]; + var (nodePath, error) = ResolvePath(path); + if (error != null) + { + Print($"Error: {error}"); + return; + } + + var leaf = nodePath.Last(); + bool isProtected = leaf.Parent is AccountInfo && leaf.Member != null && + (string.Equals(leaf.Member.Name, "Password", StringComparison.OrdinalIgnoreCase) || + string.Equals(leaf.Member.Name, "StoragePassword", StringComparison.OrdinalIgnoreCase)); + + if (isProtected) + { + Print($"{path}=[Protected]"); + } + else + { + ShowObject(leaf.Value, path, limitElements: false); + } + } + else if (sub == "get") + { + if (parts.Length < 3) + { + Print("Usage: account get "); + return; + } + + string path = parts[2]; + var (nodePath, error) = ResolvePath(path); + if (error != null) + { + Print($"Error: {error}"); + return; + } + + var leaf = nodePath.Last(); + bool isProtected = leaf.Parent is AccountInfo && leaf.Member != null && + (string.Equals(leaf.Member.Name, "Password", StringComparison.OrdinalIgnoreCase) || + string.Equals(leaf.Member.Name, "StoragePassword", StringComparison.OrdinalIgnoreCase)); + + if (isProtected) + { + Print($"{path}=[Protected]"); + } + else + { + Print($"{path}={leaf.Value}"); + } + } + else if (sub == "set") + { + if (parts.Length < 3) + { + Print("Usage: account set ="); + return; + } + + string fullArg = string.Join(" ", parts.Skip(2)); + int eqIdx = fullArg.IndexOf('='); + if (eqIdx < 0) + { + Print("Usage: account set ="); + return; + } + + string path = fullArg.Substring(0, eqIdx).Trim(); + string valStr = fullArg.Substring(eqIdx + 1).Trim(); + + var (nodePath, error) = ResolvePath(path); + if (error != null) + { + Print($"Error: {error}"); + return; + } + + var leaf = nodePath.Last(); + Type targetType = GetNodeTargetType(leaf); + if (targetType == null) + { + Print($"Error: Cannot determine type of field/property for '{path}'."); + return; + } + + object parsedVal; + try + { + parsedVal = ParseValue(valStr, targetType); + } + catch (Exception ex) + { + Print($"Error parsing value '{valStr}' to {targetType.Name}: {ex.Message}"); + return; + } + + try + { + SetAndPropagate(nodePath, parsedVal); + } + catch (Exception ex) + { + Print($"Error setting value: {ex.Message}"); + return; + } + + NotifyAndSync(nodePath); + + Print($"{path} set to {parsedVal}"); + } + else + { + Print($"Unknown account subcommand '{sub}'. Type 'account help' for details."); + } + } + + private class PathNode + { + public object Value { get; set; } + public object Parent { get; set; } + public MemberInfo Member { get; set; } + public int? Index { get; set; } + public Stat? StatKey { get; set; } + } + + private (List pathNodes, string error) ResolvePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return (null, "Path is empty."); + + var segments = path.Split('.'); + if (segments.Length == 0) + return (null, "Invalid path."); + + string firstSeg = segments[0]; + var (accountID, rootIndex) = ParseSegmentNameAndIndex(firstSeg); + + var account = Envir.Main.GetAccount(accountID); + if (account == null) + return (null, $"Account '{accountID}' not found."); + + var pathNodes = new List(); + + PathNode current; + if (rootIndex.HasValue) + { + return (null, $"Root account ID '{accountID}' cannot be indexed."); + } + else + { + current = new PathNode { Value = account }; + pathNodes.Add(current); + } + + for (int i = 1; i < segments.Length; i++) + { + var (segName, index) = ParseSegmentNameAndIndex(segments[i]); + var next = ResolveSegment(current, segName, index); + if (next == null) + { + return (null, $"Could not resolve segment '{segments[i]}' on '{GetNodePathString(pathNodes)}'."); + } + pathNodes.Add(next); + current = next; + } + + return (pathNodes, null); + } + + private (string name, int? index) ParseSegmentNameAndIndex(string segment) + { + segment = segment.Trim(); + int bracketStart = segment.IndexOf('['); + if (bracketStart >= 0) + { + int bracketEnd = segment.IndexOf(']'); + if (bracketEnd > bracketStart) + { + string name = segment.Substring(0, bracketStart).Trim(); + string idxStr = segment.Substring(bracketStart + 1, bracketEnd - bracketStart - 1).Trim(); + if (int.TryParse(idxStr, out int idx)) + { + return (name, idx); + } + } + } + return (segment, null); + } + + private string GetNodePathString(List pathNodes) + { + var sb = new StringBuilder(); + foreach (var node in pathNodes) + { + if (sb.Length > 0) sb.Append("."); + if (node.Value is AccountInfo acc) sb.Append(acc.AccountID); + else if (node.Member != null) + { + sb.Append(node.Member.Name); + if (node.Index.HasValue) sb.Append($"[{node.Index.Value}]"); + } + else if (node.StatKey.HasValue) + { + sb.Append(node.StatKey.Value.ToString()); + } + } + return sb.ToString(); + } + + private PathNode ResolveSegment(PathNode current, string segmentName, int? index) + { + object obj = current.Value; + if (obj == null) return null; + + Type type = obj.GetType(); + + var members = type.GetMember(segmentName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + var member = members.FirstOrDefault(m => m is FieldInfo || m is PropertyInfo); + + if (member != null) + { + object nextVal = GetMemberValue(member, obj); + if (index.HasValue) + { + if (nextVal is Array arr) + { + if (index.Value < 0 || index.Value >= arr.Length) return null; + return new PathNode + { + Value = arr.GetValue(index.Value), + Parent = arr, + Member = member, + Index = index.Value + }; + } + else if (nextVal is IList list) + { + if (index.Value < 0 || index.Value >= list.Count) return null; + return new PathNode + { + Value = list[index.Value], + Parent = list, + Member = member, + Index = index.Value + }; + } + return null; + } + else + { + return new PathNode + { + Value = nextVal, + Parent = obj, + Member = member + }; + } + } + + if (obj is AccountInfo acc) + { + var characters = acc.Characters; + var foundChar = characters.FirstOrDefault(c => string.Equals(c.Name, segmentName, StringComparison.OrdinalIgnoreCase)); + if (foundChar != null) + { + if (index.HasValue) return null; + return new PathNode + { + Value = foundChar, + Parent = characters, + Member = typeof(AccountInfo).GetField("Characters") + }; + } + } + + if (obj is UserItem item) + { + if (Enum.TryParse(segmentName, true, out var stat)) + { + if (index.HasValue) return null; + return new PathNode + { + Value = item.AddedStats[stat], + Parent = item.AddedStats, + StatKey = stat + }; + } + } + + if (obj is Stats stats) + { + if (Enum.TryParse(segmentName, true, out var stat)) + { + if (index.HasValue) return null; + return new PathNode + { + Value = stats[stat], + Parent = stats, + StatKey = stat + }; + } + } + + return null; + } + + private object GetMemberValue(MemberInfo member, object obj) + { + if (member is FieldInfo f) return f.GetValue(obj); + if (member is PropertyInfo p) return p.GetValue(obj); + return null; + } + + private Type GetNodeTargetType(PathNode node) + { + if (node.StatKey.HasValue) + { + return typeof(int); + } + if (node.Member != null) + { + Type type = (node.Member is FieldInfo f) ? f.FieldType : ((PropertyInfo)node.Member).PropertyType; + if (node.Index.HasValue) + { + if (type.IsArray) + { + return type.GetElementType(); + } + else if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) + { + return type.GetGenericArguments()[0]; + } + } + else + { + return type; + } + } + return null; + } + + private object ParseValue(string val, Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + if (type == typeof(string)) return val; + if (type == typeof(bool)) + { + val = val.Trim().ToLowerInvariant(); + if (val == "true" || val == "1" || val == "yes" || val == "on" || val == "enable") return true; + if (val == "false" || val == "0" || val == "no" || val == "off" || val == "disable") return false; + return bool.Parse(val); + } + if (type.IsEnum) + { + return Enum.Parse(type, val, true); + } + if (type == typeof(Point)) + { + var parts = val.Split(new char[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 2 && int.TryParse(parts[0], out int x) && int.TryParse(parts[1], out int y)) + { + return new Point(x, y); + } + throw new ArgumentException("Point must be in format 'X,Y'"); + } + return Convert.ChangeType(val, type, System.Globalization.CultureInfo.InvariantCulture); + } + + private void ApplyValue(PathNode node, object parsedValue) + { + if (node.StatKey.HasValue) + { + if (node.Parent is Stats stats) + { + stats[node.StatKey.Value] = (int)parsedValue; + } + } + else if (node.Member != null) + { + if (node.Index.HasValue) + { + if (node.Parent is Array arr) + { + arr.SetValue(parsedValue, node.Index.Value); + } + else if (node.Parent is IList list) + { + list[node.Index.Value] = parsedValue; + } + } + else + { + if (node.Member is FieldInfo f) + { + f.SetValue(node.Parent, parsedValue); + } + else if (node.Member is PropertyInfo p) + { + p.SetValue(node.Parent, parsedValue); + } + } + } + } + + private void SetAndPropagate(List path, object parsedValue) + { + var leaf = path.Last(); + ApplyValue(leaf, parsedValue); + + for (int i = path.Count - 1; i > 0; i--) + { + var current = path[i]; + var parentNode = path[i - 1]; + + if (current.Parent != null && current.Parent.GetType().IsValueType) + { + parentNode.Value = current.Parent; + ApplyValue(current, current.Parent); + } + else + { + break; + } + } + } + + private void NotifyAndSync(List path) + { + CharacterInfo charInfo = null; + UserItem userItem = null; + + foreach (var node in path) + { + if (node.Value is CharacterInfo ci) + { + charInfo = ci; + } + else if (node.Value is UserItem ui) + { + userItem = ui; + } + else if (node.Parent is UserItem uiParent) + { + userItem = uiParent; + } + } + + if (charInfo != null && charInfo.Player != null) + { + var player = charInfo.Player; + + var levelNode = path.FirstOrDefault(n => n.Member != null && string.Equals(n.Member.Name, "Level", StringComparison.OrdinalIgnoreCase)); + if (levelNode != null && levelNode.Parent == charInfo) + { + player.Level = charInfo.Level; + player.LevelUp(); + } + + if (userItem != null) + { + player.Enqueue(new ServerPackets.RefreshItem { Item = userItem }); + } + + player.RefreshStats(); + } + + var accountNode = path.FirstOrDefault(n => n.Value is AccountInfo); + if (accountNode != null && accountNode.Value is AccountInfo accInfo) + { + foreach (var character in accInfo.Characters) + { + if (character.Player != null) + { + var goldNode = path.FirstOrDefault(n => n.Member != null && (string.Equals(n.Member.Name, "Gold", StringComparison.OrdinalIgnoreCase) || string.Equals(n.Member.Name, "Credit", StringComparison.OrdinalIgnoreCase))); + if (goldNode != null && goldNode.Parent == accInfo) + { + character.Player.GetUserInfo(character.Player.Connection); + } + } + } + } + } + + private void ShowObject(object obj, string pathPrefix, bool limitElements = true) + { + if (obj == null) + { + Print($"{pathPrefix}=null"); + return; + } + + Type type = obj.GetType(); + + if (type == typeof(byte) || type == typeof(byte[]) || type == typeof(byte?)) + { + return; + } + + if (IsSimpleType(type)) + { + Print($"{pathPrefix}={obj}"); + return; + } + + if (obj is Array arr) + { + if (type.GetElementType() == typeof(byte)) + { + return; + } + Print($"{pathPrefix}=Array[{arr.Length}]"); + int printed = 0; + for (int i = 0; i < arr.Length; i++) + { + var elem = arr.GetValue(i); + if (elem != null) + { + if (!limitElements || printed < 5) + { + Print($" {pathPrefix}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < arr.Length; j++) + { + if (arr.GetValue(j) != null) remaining++; + } + Print($" {pathPrefix}[...] (omitting {remaining} elements)"); + break; + } + } + } + return; + } + + if (obj is IList list) + { + Print($"{pathPrefix}=List[{list.Count}]"); + int printed = 0; + for (int i = 0; i < list.Count; i++) + { + var elem = list[i]; + if (elem != null) + { + if (!limitElements || printed < 5) + { + Print($" {pathPrefix}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < list.Count; j++) + { + if (list[j] != null) remaining++; + } + Print($" {pathPrefix}[...] (omitting {remaining} elements)"); + break; + } + } + } + return; + } + + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); + foreach (var field in fields) + { + if (field.FieldType == typeof(byte) || field.FieldType == typeof(byte[]) || field.FieldType == typeof(byte?)) + { + continue; + } + + string fieldPath = $"{pathPrefix}.{field.Name}"; + object val = field.GetValue(obj); + + if (val == null) + { + Print($"{fieldPath}=null"); + } + else if (obj is AccountInfo && (string.Equals(field.Name, "Password", StringComparison.OrdinalIgnoreCase) || string.Equals(field.Name, "StoragePassword", StringComparison.OrdinalIgnoreCase))) + { + Print($"{fieldPath}=[Protected]"); + } + else if (IsSimpleType(field.FieldType)) + { + Print($"{fieldPath}={val}"); + } + else if (field.FieldType.IsArray) + { + var fieldArr = (Array)val; + if (field.FieldType.GetElementType() == typeof(byte)) + { + continue; + } + Print($"{fieldPath}=Array[{fieldArr.Length}]"); + int printed = 0; + for (int i = 0; i < fieldArr.Length; i++) + { + var elem = fieldArr.GetValue(i); + if (elem != null) + { + if (printed < 5) + { + Print($" {fieldPath}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < fieldArr.Length; j++) + { + if (fieldArr.GetValue(j) != null) remaining++; + } + Print($" {fieldPath}[...] (omitting {remaining} elements)"); + break; + } + } + } + } + else if (typeof(IList).IsAssignableFrom(field.FieldType)) + { + var fieldList = (IList)val; + Print($"{fieldPath}=List[{fieldList.Count}]"); + int printed = 0; + for (int i = 0; i < fieldList.Count; i++) + { + var elem = fieldList[i]; + if (elem != null) + { + if (printed < 5) + { + Print($" {fieldPath}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < fieldList.Count; j++) + { + if (fieldList[j] != null) remaining++; + } + Print($" {fieldPath}[...] (omitting {remaining} elements)"); + break; + } + } + } + } + else if (val is Stats stats) + { + Print($"{fieldPath}=Stats"); + foreach (var kvp in stats.Values) + { + Print($" {fieldPath}.{kvp.Key}={kvp.Value}"); + } + } + else + { + Print($"{fieldPath}={GetBriefDescription(val)}"); + } + } + + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in props) + { + if (prop.GetIndexParameters().Length > 0) continue; + if (prop.PropertyType == typeof(byte) || prop.PropertyType == typeof(byte[]) || prop.PropertyType == typeof(byte?)) + { + continue; + } + + string propPath = $"{pathPrefix}.{prop.Name}"; + object val; + try + { + val = prop.GetValue(obj); + } + catch + { + continue; + } + + if (val == null) + { + Print($"{propPath}=null"); + } + else if (obj is AccountInfo && (string.Equals(prop.Name, "Password", StringComparison.OrdinalIgnoreCase) || string.Equals(prop.Name, "StoragePassword", StringComparison.OrdinalIgnoreCase))) + { + Print($"{propPath}=[Protected]"); + } + else if (IsSimpleType(prop.PropertyType)) + { + Print($"{propPath}={val}"); + } + else if (prop.PropertyType.IsArray) + { + var propArr = (Array)val; + if (prop.PropertyType.GetElementType() == typeof(byte)) + { + continue; + } + Print($"{propPath}=Array[{propArr.Length}]"); + int printed = 0; + for (int i = 0; i < propArr.Length; i++) + { + var elem = propArr.GetValue(i); + if (elem != null) + { + if (printed < 5) + { + Print($" {propPath}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < propArr.Length; j++) + { + if (propArr.GetValue(j) != null) remaining++; + } + Print($" {propPath}[...] (omitting {remaining} elements)"); + break; + } + } + } + } + else if (typeof(IList).IsAssignableFrom(prop.PropertyType)) + { + var propList = (IList)val; + Print($"{propPath}=List[{propList.Count}]"); + int printed = 0; + for (int i = 0; i < propList.Count; i++) + { + var elem = propList[i]; + if (elem != null) + { + if (printed < 5) + { + Print($" {propPath}[{i}]={GetBriefDescription(elem)}"); + printed++; + } + else + { + int remaining = 0; + for (int j = i; j < propList.Count; j++) + { + if (propList[j] != null) remaining++; + } + Print($" {propPath}[...] (omitting {remaining} elements)"); + break; + } + } + } + } + else if (val is Stats stats) + { + Print($"{propPath}=Stats"); + foreach (var kvp in stats.Values) + { + Print($" {propPath}.{kvp.Key}={kvp.Value}"); + } + } + else + { + Print($"{propPath}={GetBriefDescription(val)}"); + } + } + } + + private bool IsSimpleType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive || type == typeof(string) || type.IsEnum || type == typeof(DateTime) || type == typeof(Point); + } + + private string GetBriefDescription(object elem) + { + if (elem == null) return "(null)"; + if (elem is CharacterInfo ci) return $"Character: {ci.Name} (Level: {ci.Level})"; + if (elem is UserItem ui) return ui.Info != null ? ui.FriendlyName : $"Item {ui.ItemIndex}"; + if (elem is AccountInfo acc) return $"Account: {acc.AccountID}"; + return elem.GetType().Name; + } } } diff --git a/Server.Headless/README.md b/Server.Headless/README.md index c2298c6f0..ab36d9dfc 100644 --- a/Server.Headless/README.md +++ b/Server.Headless/README.md @@ -47,7 +47,48 @@ Or run the published executable directly: ``` ### 4. Graceful Shutdown -To stop the server safely, press `Ctrl + C` in the terminal. The headless host intercepts the `SIGINT` signal to halt the game loop, securely save the database state, and commit all configurations before fully exiting. +To stop the server safely, press `Ctrl + C` in the terminal or type `exit` in the console. The headless host intercepts the shutdown request to halt the game loop, securely save the database state, and commit all configurations before fully exiting. + +--- + +## 🎮 CLI Interactive Console & `account` Command + +The headless server starts an interactive console supporting auto-completion (tab-completion) and administrative commands. + +### Interactive Commands +- `help` / `?`: Show the list of available commands. +- `exit`: Gracefully stop the server, saving the database state and committing configurations. +- `account`: View and edit user accounts and their nested attributes (characters, items, stats) in an OpenWrt `uci`-like format. + +#### Account Path Notation & Subcommands +The `account` command works by resolving a dot-notation path, for example: `AccountID.Characters[Index].Inventory[Index].Field` or `AccountID.CharacterName.Inventory[Index].StatName`. + +Available subcommands: +- **`account show`**: List all accounts, or recursively display nested attributes at a resolved path. + ```bash + account show asdf + account show asdf.Honoka + account show asdf.Honoka.Inventory + ``` + *Note on array truncation:* When showing an object, nested arrays or lists containing more than 5 elements are truncated (omitting the remainder) to avoid terminal flooding. However, if the queried target *is* an array/list itself (e.g. `account show asdf.Honoka.Inventory`), it will display all elements without limits. + *Note on privacy:* Sensitive fields like `Password` and `StoragePassword` are automatically displayed as `[Protected]` to prevent ANSI-corrupted/garbled text output. + +- **`account get `**: Get the value of a specific attribute. + ```bash + account get asdf.Gold + account get asdf.Honoka.HP + ``` + +- **`account set =`**: Modify the value of a specific attribute. + ```bash + account set asdf.Gold=1000 + account set asdf.Honoka.Level=15 + account set asdf.Honoka.Inventory[0].Luck=9 + ``` + *Shortcuts:* + - If a segment is not found on `AccountInfo`, it will search the `Characters` list for a character matching the name (e.g., `asdf.Honoka` instead of `asdf.Characters[0]`). + - If a segment is not found on `UserItem`, it will search the `Stat` enum and direct the set operation to the item's `AddedStats` collection. + - Updates to online players (e.g. leveling up, item stats modifications, gold/credits changes) are automatically synchronized in real-time. --- From b76a64645cb344e899de280bc7c5f603074cd25f Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sat, 30 May 2026 01:36:49 +0900 Subject: [PATCH 28/31] Fix MinMC and Other Min/Max Added Stats Rendering in UI I have successfully resolved the tooltip rendering bug in the game client where added minimum stats (such as MinMC) were not displayed. --- Client/MirScenes/GameScene.cs | 146 ++++++++++++++++++++++++++-------- 1 file changed, 115 insertions(+), 31 deletions(-) diff --git a/Client/MirScenes/GameScene.cs b/Client/MirScenes/GameScene.cs index 0fcef0f1e..e5ae04b62 100644 --- a/Client/MirScenes/GameScene.cs +++ b/Client/MirScenes/GameScene.cs @@ -7171,6 +7171,8 @@ public MirControl AttackInfoLabel(UserItem item, bool Inspect = false, bool hide int minValue = 0; int maxValue = 0; int addValue = 0; + int addValueMin = 0; + int addValueMax = 0; string text = ""; #region Dura gem @@ -7208,19 +7210,35 @@ public MirControl AttackInfoLabel(UserItem item, bool Inspect = false, bool hide #region DC minValue = realItem.Stats[Stat.MinDC]; maxValue = realItem.Stats[Stat.MaxDC]; - addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxDC] : 0; + addValueMin = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MinDC] : 0; + addValueMax = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxDC] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + if (minValue > 0 || maxValue > 0 || addValueMin > 0 || addValueMax > 0) { count++; if (HoverItem.Info.Type != ItemType.Gem) - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.DC, minValue, maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : string.Empty); + { + string addText = string.Empty; + if (addValueMin > 0 && addValueMax > 0) + { + addText = addValueMin == addValueMax ? $" (+{addValueMax})" : $" (+{addValueMin}-+{addValueMax})"; + } + else if (addValueMin > 0) + { + addText = $" (+{addValueMin}-+0)"; + } + else if (addValueMax > 0) + { + addText = $" (+{addValueMax})"; + } + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.DC, minValue + addValueMin, maxValue + addValueMax) + addText; + } else - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsDC, minValue + maxValue + addValue); + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsDC, minValue + maxValue + addValueMin + addValueMax); MirLabel DCLabel = new MirLabel { AutoSize = true, - ForeColour = addValue > 0 ? Color.Cyan : Color.White, + ForeColour = (addValueMin > 0 || addValueMax > 0) ? Color.Cyan : Color.White, Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, @@ -7237,19 +7255,35 @@ public MirControl AttackInfoLabel(UserItem item, bool Inspect = false, bool hide minValue = realItem.Stats[Stat.MinMC]; maxValue = realItem.Stats[Stat.MaxMC]; - addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxMC] : 0; + addValueMin = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MinMC] : 0; + addValueMax = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxMC] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + if (minValue > 0 || maxValue > 0 || addValueMin > 0 || addValueMax > 0) { count++; if (HoverItem.Info.Type != ItemType.Gem) - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.MC, minValue, maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : string.Empty); + { + string addText = string.Empty; + if (addValueMin > 0 && addValueMax > 0) + { + addText = addValueMin == addValueMax ? $" (+{addValueMax})" : $" (+{addValueMin}-+{addValueMax})"; + } + else if (addValueMin > 0) + { + addText = $" (+{addValueMin}-+0)"; + } + else if (addValueMax > 0) + { + addText = $" (+{addValueMax})"; + } + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.MC, minValue + addValueMin, maxValue + addValueMax) + addText; + } else - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsMC, minValue + maxValue + addValue); + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsMC, minValue + maxValue + addValueMin + addValueMax); MirLabel MCLabel = new MirLabel { AutoSize = true, - ForeColour = addValue > 0 ? Color.Cyan : Color.White, + ForeColour = (addValueMin > 0 || addValueMax > 0) ? Color.Cyan : Color.White, Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, @@ -7266,19 +7300,35 @@ public MirControl AttackInfoLabel(UserItem item, bool Inspect = false, bool hide minValue = realItem.Stats[Stat.MinSC]; maxValue = realItem.Stats[Stat.MaxSC]; - addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxSC] : 0; + addValueMin = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MinSC] : 0; + addValueMax = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxSC] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + if (minValue > 0 || maxValue > 0 || addValueMin > 0 || addValueMax > 0) { count++; if (HoverItem.Info.Type != ItemType.Gem) - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.SC, minValue, maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : string.Empty); + { + string addText = string.Empty; + if (addValueMin > 0 && addValueMax > 0) + { + addText = addValueMin == addValueMax ? $" (+{addValueMax})" : $" (+{addValueMin}-+{addValueMax})"; + } + else if (addValueMin > 0) + { + addText = $" (+{addValueMin}-+0)"; + } + else if (addValueMax > 0) + { + addText = $" (+{addValueMax})"; + } + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.SC, minValue + addValueMin, maxValue + addValueMax) + addText; + } else - text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsSC, minValue + maxValue + addValue); + text = GameLanguage.ClientTextMap.GetLocalization(ClientTextKeys.AddsSC, minValue + maxValue + addValueMin + addValueMax); MirLabel SCLabel = new MirLabel { AutoSize = true, - ForeColour = addValue > 0 ? Color.Cyan : Color.White, + ForeColour = (addValueMin > 0 || addValueMax > 0) ? Color.Cyan : Color.White, Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, @@ -7787,25 +7837,43 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid int minValue = 0; int maxValue = 0; int addValue = 0; + int addValueMin = 0; + int addValueMax = 0; string text = ""; #region AC minValue = realItem.Stats[Stat.MinAC]; maxValue = realItem.Stats[Stat.MaxAC]; - addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxAC] : 0; + addValueMin = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MinAC] : 0; + addValueMax = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxAC] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + if (minValue > 0 || maxValue > 0 || addValueMin > 0 || addValueMax > 0) { count++; if (HoverItem.Info.Type != ItemType.Gem) - text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AC), minValue, maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : string.Empty); + { + string addText = string.Empty; + if (addValueMin > 0 && addValueMax > 0) + { + addText = addValueMin == addValueMax ? $" (+{addValueMax})" : $" (+{addValueMin}-+{addValueMax})"; + } + else if (addValueMin > 0) + { + addText = $" (+{addValueMin}-+0)"; + } + else if (addValueMax > 0) + { + addText = $" (+{addValueMax})"; + } + text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AC), minValue + addValueMin, maxValue + addValueMax) + addText; + } else - text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AddsAC), minValue + maxValue + addValue); + text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AddsAC), minValue + maxValue + addValueMin + addValueMax); MirLabel ACLabel = new MirLabel { AutoSize = true, - ForeColour = addValue > 0 ? Color.Cyan : Color.White, + ForeColour = (addValueMin > 0 || addValueMax > 0) ? Color.Cyan : Color.White, Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, @@ -7817,17 +7885,17 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid { if (HoverItem.Info.Type == ItemType.Float) { - ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.NibbleChance), minValue, maxValue + addValue) + - (addValue > 0 ? $" (+{addValue})" : String.Empty); + ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.NibbleChance), minValue, maxValue + addValueMax) + + (addValueMax > 0 ? $" (+{addValueMax})" : String.Empty); } else if (HoverItem.Info.Type == ItemType.Finder) { - ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.FinderIncrease), minValue, maxValue + addValue) + - (addValue > 0 ? $" (+{addValue})" : String.Empty); + ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.FinderIncrease), minValue, maxValue + addValueMax) + + (addValueMax > 0 ? $" (+{addValueMax})" : String.Empty); } else { - ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.SuccessChance), maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : String.Empty); + ACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.SuccessChance), maxValue + addValueMax) + (addValueMax > 0 ? $" (+{addValueMax})" : String.Empty); } } @@ -7841,19 +7909,35 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid minValue = realItem.Stats[Stat.MinMAC]; maxValue = realItem.Stats[Stat.MaxMAC]; - addValue = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxMAC] : 0; + addValueMin = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MinMAC] : 0; + addValueMax = (!hideAdded && (!HoverItem.Info.NeedIdentify || HoverItem.Identified)) ? addedStats[Stat.MaxMAC] : 0; - if (minValue > 0 || maxValue > 0 || addValue > 0) + if (minValue > 0 || maxValue > 0 || addValueMin > 0 || addValueMax > 0) { count++; if (HoverItem.Info.Type != ItemType.Gem) - text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MAC), minValue, maxValue + addValue) + (addValue > 0 ? $" (+{addValue})" : string.Empty); + { + string addText = string.Empty; + if (addValueMin > 0 && addValueMax > 0) + { + addText = addValueMin == addValueMax ? $" (+{addValueMax})" : $" (+{addValueMin}-+{addValueMax})"; + } + else if (addValueMin > 0) + { + addText = $" (+{addValueMin}-+0)"; + } + else if (addValueMax > 0) + { + addText = $" (+{addValueMax})"; + } + text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.MAC), minValue + addValueMin, maxValue + addValueMax) + addText; + } else - text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AddsMAC), minValue + maxValue + addValue); + text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AddsMAC), minValue + maxValue + addValueMin + addValueMax); MirLabel MACLabel = new MirLabel { AutoSize = true, - ForeColour = addValue > 0 ? Color.Cyan : Color.White, + ForeColour = (addValueMin > 0 || addValueMax > 0) ? Color.Cyan : Color.White, Location = new Point(4, ItemLabel.DisplayRectangle.Bottom), OutLine = true, Parent = ItemLabel, @@ -7863,7 +7947,7 @@ public MirControl DefenceInfoLabel(UserItem item, bool Inspect = false, bool hid if (fishingItem) { - MACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AutoReelChance), maxValue + addValue); + MACLabel.Text = GameLanguage.ClientTextMap.GetLocalization((ClientTextKeys.AutoReelChance), maxValue + addValueMax); } ItemLabel.Size = new Size(Math.Max(ItemLabel.Size.Width, MACLabel.DisplayRectangle.Right + 4), From 5d4ac22ff2f710fd2b78d941747d17cdaaba3877 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sat, 30 May 2026 11:59:22 +0900 Subject: [PATCH 29/31] Implement account command modifications & extended autocomplete We have implemented modifications to the account CLI command in the headless server console to handle arrays/lists, sensitive password fields, and extended autocomplete. --- Server.Headless/CommandProcessor.cs | 94 ++++++++++++++++++++++++++++- Server.Headless/README.md | 3 +- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Server.Headless/CommandProcessor.cs b/Server.Headless/CommandProcessor.cs index dd3d6c7af..db0b6f0b1 100644 --- a/Server.Headless/CommandProcessor.cs +++ b/Server.Headless/CommandProcessor.cs @@ -646,9 +646,43 @@ private List GetCompletions(string text, out int prefixLength) } else if (parts.Count == 3 && !endsWithSpace) { - string word = parts[2]; - prefixLength = word.Length; - completions.AddRange(Envir.Main.AccountList.Select(x => x.AccountID).Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + string pathArg = parts[2]; + if (pathArg.Contains('=')) + { + // Do not autocomplete after equals sign + } + else + { + int lastDot = pathArg.LastIndexOf('.'); + if (lastDot >= 0) + { + string parentPath = pathArg.Substring(0, lastDot); + string segmentPrefix = pathArg.Substring(lastDot + 1); + + var (nodePath, error) = ResolvePath(parentPath); + if (error == null && nodePath != null && nodePath.Count > 0) + { + var parentNode = nodePath.Last(); + object parentObj = parentNode.Value; + if (parentObj != null) + { + var possibleMembers = GetNextSegmentCompletions(parentObj); + var filtered = possibleMembers + .Where(m => m.StartsWith(segmentPrefix, StringComparison.OrdinalIgnoreCase)) + .Select(m => parentPath + "." + m); + + prefixLength = pathArg.Length; + completions.AddRange(filtered); + } + } + } + else + { + string word = pathArg; + prefixLength = word.Length; + completions.AddRange(Envir.Main.AccountList.Select(x => x.AccountID).Where(c => c.StartsWith(word, StringComparison.OrdinalIgnoreCase))); + } + } } } } @@ -656,6 +690,60 @@ private List GetCompletions(string text, out int prefixLength) return completions; } + private List GetNextSegmentCompletions(object parentObj) + { + var completions = new List(); + if (parentObj == null) return completions; + + Type type = parentObj.GetType(); + + // 1. Add fields (excluding byte types) + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Instance); + foreach (var field in fields) + { + if (field.FieldType == typeof(byte) || field.FieldType == typeof(byte[]) || field.FieldType == typeof(byte?)) + { + continue; + } + completions.Add(field.Name); + } + + // 2. Add properties (excluding indexers and byte types) + var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var prop in props) + { + if (prop.GetIndexParameters().Length > 0) continue; + if (prop.PropertyType == typeof(byte) || prop.PropertyType == typeof(byte[]) || prop.PropertyType == typeof(byte?)) + { + continue; + } + completions.Add(prop.Name); + } + + // 3. Shortcuts: Character names for AccountInfo + if (parentObj is AccountInfo acc) + { + foreach (var character in acc.Characters) + { + if (!string.IsNullOrEmpty(character.Name)) + { + completions.Add(character.Name); + } + } + } + + // 4. Shortcuts: Stat enum names for UserItem and Stats + if (parentObj is UserItem || parentObj is Stats) + { + foreach (var name in Enum.GetNames(typeof(Stat))) + { + completions.Add(name); + } + } + + return completions.Distinct().ToList(); + } + private List ParseArgumentsForCompletion(string text) { var list = new List(); diff --git a/Server.Headless/README.md b/Server.Headless/README.md index ab36d9dfc..fd3f6dc0e 100644 --- a/Server.Headless/README.md +++ b/Server.Headless/README.md @@ -85,7 +85,8 @@ Available subcommands: account set asdf.Honoka.Level=15 account set asdf.Honoka.Inventory[0].Luck=9 ``` - *Shortcuts:* + *Shortcuts & Autocomplete:* + - **Dynamic Tab Completion**: Pressing Tab on any path argument (e.g. `asdf.`) dynamically inspects the C# object hierarchy using reflection and offers completions for all nested fields, properties, character names, and `Stat` shortcuts. - If a segment is not found on `AccountInfo`, it will search the `Characters` list for a character matching the name (e.g., `asdf.Honoka` instead of `asdf.Characters[0]`). - If a segment is not found on `UserItem`, it will search the `Stat` enum and direct the set operation to the item's `AddedStats` collection. - Updates to online players (e.g. leveling up, item stats modifications, gold/credits changes) are automatically synchronized in real-time. From 389b98337455a2cecd3f4d5b3d5a475c77435d2a Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Sat, 30 May 2026 15:24:06 +0900 Subject: [PATCH 30/31] Formatting Spacing Fix Updated PrintTiledCompletions in CommandProcessor.cs to calculate column widths dynamically based on the maximum width of the suggestion strings (int maxLen = options.Max(o => GetStringWidth(o)) and colWidth = maxLen + 2, maintaining a minimum of 16). This ensures long dot-notation paths are formatted cleanly and clearly separated with appropriate spacing in the terminal. --- Server.Headless/CommandProcessor.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Server.Headless/CommandProcessor.cs b/Server.Headless/CommandProcessor.cs index db0b6f0b1..42825d201 100644 --- a/Server.Headless/CommandProcessor.cs +++ b/Server.Headless/CommandProcessor.cs @@ -142,7 +142,9 @@ private void PrintTiledCompletions(List options) lock (ConsoleLock) { Console.WriteLine(); - const int colWidth = 16; + int maxLen = options.Count > 0 ? options.Max(o => GetStringWidth(o)) : 0; + int colWidth = maxLen + 2; + if (colWidth < 16) colWidth = 16; int windowWidth = 80; try { windowWidth = Console.WindowWidth; } catch {} int lineLen = 0; From b0bb63ca71fde31da54d433710988071a938fde2 Mon Sep 17 00:00:00 2001 From: AuroraRAS Date: Mon, 1 Jun 2026 18:06:42 +0900 Subject: [PATCH 31/31] Fix Tab Key Input Handlers in FNA Client Restricted keyboard event processing inside OnKeyDown and OnKeyUp to run only when the text box is actually focused (_isFocused == true). This stops unfocused or invisible text box controls (like the persistent ChatTextBox inside ChatDialog) from prematurely marking Tab or Escape key events as handled, ensuring they correctly bubble up to the active game scene. --- Client/MirControls/MirTextBox.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Client/MirControls/MirTextBox.cs b/Client/MirControls/MirTextBox.cs index c9eeea1a2..2510776eb 100644 --- a/Client/MirControls/MirTextBox.cs +++ b/Client/MirControls/MirTextBox.cs @@ -193,6 +193,8 @@ public void LoseFocus() public override void OnKeyDown(KeyEventArgs e) { + if (!_isFocused) return; + base.OnKeyDown(e); KeyDownEvent?.Invoke(this, e); if (e.Handled) return; @@ -220,6 +222,8 @@ public override void OnKeyDown(KeyEventArgs e) public override void OnKeyUp(KeyEventArgs e) { + if (!_isFocused) return; + base.OnKeyUp(e); KeyUpEvent?.Invoke(this, e); }