From bc4405ff649cf299496faaa508b59bec67cab8a5 Mon Sep 17 00:00:00 2001 From: UnknownShadow200 Date: Fri, 16 Oct 2015 20:31:37 +1100 Subject: [PATCH] More work on launcher, copy stuff across from old launcher. --- ClassicalSharp.sln | 10 +- InteropPatcher/InteropPatcher.csproj | 2 +- Launcher2/FastButtonWidget.cs | 50 +++++++ Launcher2/GameStartData.cs | 25 ++++ Launcher2/Launcher2.csproj | 23 +++- Launcher2/MainScreen.cs | 115 +++++++++------- Launcher2/Patcher/Animations.cs | 50 +++++++ Launcher2/Patcher/ResourceFetcher.cs | 159 ++++++++++++++++++++++ Launcher2/Patcher/ZipWriter.cs | 122 +++++++++++++++++ Launcher2/Program.cs | 26 +++- Launcher2/WebService/ClassiCubeSession.cs | 129 ++++++++++++++++++ Launcher2/WebService/GameSession.cs | 74 ++++++++++ Launcher2/WebService/ServerListEntry.cs | 29 ++++ Launcher2/WebService/WebUtility.cs | 128 +++++++++++++++++ 14 files changed, 878 insertions(+), 64 deletions(-) create mode 100644 Launcher2/FastButtonWidget.cs create mode 100644 Launcher2/GameStartData.cs create mode 100644 Launcher2/Patcher/Animations.cs create mode 100644 Launcher2/Patcher/ResourceFetcher.cs create mode 100644 Launcher2/Patcher/ZipWriter.cs create mode 100644 Launcher2/WebService/ClassiCubeSession.cs create mode 100644 Launcher2/WebService/GameSession.cs create mode 100644 Launcher2/WebService/ServerListEntry.cs create mode 100644 Launcher2/WebService/WebUtility.cs diff --git a/ClassicalSharp.sln b/ClassicalSharp.sln index 229e42ed7..ddb03a66a 100644 --- a/ClassicalSharp.sln +++ b/ClassicalSharp.sln @@ -1,6 +1,6 @@  -Microsoft Visual Studio Solution File, Format Version 11.00 -# Visual Studio 2010 +Microsoft Visual Studio Solution File, Format Version 10.00 +# Visual Studio 2008 # SharpDevelop 4.4 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassicalSharp", "ClassicalSharp\ClassicalSharp.csproj", "{BEB1C785-5CAD-48FF-A886-876BF0A318D4}" EndProject @@ -27,7 +27,7 @@ Global {23B9BDA8-4330-46AB-9012-08D87430391A}.Debug_DX|Any CPU.Build.0 = Debug|Any CPU {23B9BDA8-4330-46AB-9012-08D87430391A}.Debug_DX|Any CPU.ActiveCfg = Debug|Any CPU {23B9BDA8-4330-46AB-9012-08D87430391A}.Release_DX|Any CPU.Build.0 = Release|Any CPU - {23B9BDA8-4330-46AB-9012-08D87430391A}.Release_DX|Any CPU.ActiveCfg = Release|Any CPU + {23B9BDA8-4330-46AB-9012-08D87430391A}.Release_DX|Any CPU.ActiveCfg = Release|Any CPU {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Debug|Any CPU.Build.0 = Debug|Any CPU {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Release|Any CPU.Build.0 = Release|Any CPU @@ -35,7 +35,7 @@ Global {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Debug_DX|Any CPU.Build.0 = Debug_D3D|Any CPU {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Debug_DX|Any CPU.ActiveCfg = Debug_D3D|Any CPU {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Release_DX|Any CPU.Build.0 = Release|Any CPU - {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Release_DX|Any CPU.ActiveCfg = Release_D3D|Any CPU + {BEB1C785-5CAD-48FF-A886-876BF0A318D4}.Release_DX|Any CPU.ActiveCfg = Release_D3D|Any CPU {35FEE071-2DE6-48A1-9343-B5C1F202A12B}.Debug|Any CPU.Build.0 = Debug|Any CPU {35FEE071-2DE6-48A1-9343-B5C1F202A12B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {35FEE071-2DE6-48A1-9343-B5C1F202A12B}.Release|Any CPU.Build.0 = Release|Any CPU @@ -51,7 +51,7 @@ Global {4A4110EE-21CA-4715-AF67-0C8B7CE0642F}.Debug_DX|Any CPU.Build.0 = Debug|Any CPU {4A4110EE-21CA-4715-AF67-0C8B7CE0642F}.Debug_DX|Any CPU.ActiveCfg = Debug|Any CPU {4A4110EE-21CA-4715-AF67-0C8B7CE0642F}.Release_DX|Any CPU.Build.0 = Release|Any CPU - {4A4110EE-21CA-4715-AF67-0C8B7CE0642F}.Release_DX|Any CPU.ActiveCfg = Release|Any CPU + {4A4110EE-21CA-4715-AF67-0C8B7CE0642F}.Release_DX|Any CPU.ActiveCfg = Release|Any CPU {3E84ACC1-27B4-401B-A359-6AAE4DF6C9B5}.Debug|Any CPU.Build.0 = Debug|Any CPU {3E84ACC1-27B4-401B-A359-6AAE4DF6C9B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3E84ACC1-27B4-401B-A359-6AAE4DF6C9B5}.Release|Any CPU.Build.0 = Release|Any CPU diff --git a/InteropPatcher/InteropPatcher.csproj b/InteropPatcher/InteropPatcher.csproj index 96254482a..2472627dc 100644 --- a/InteropPatcher/InteropPatcher.csproj +++ b/InteropPatcher/InteropPatcher.csproj @@ -1,5 +1,5 @@  - + {4A4110EE-21CA-4715-AF67-0C8B7CE0642F} Debug diff --git a/Launcher2/FastButtonWidget.cs b/Launcher2/FastButtonWidget.cs new file mode 100644 index 000000000..5750e18ea --- /dev/null +++ b/Launcher2/FastButtonWidget.cs @@ -0,0 +1,50 @@ +using System; +using System.Drawing; +using ClassicalSharp; +using OpenTK; + +namespace Launcher2 { + + public sealed class FastButtonWidget { + + public int ButtonWidth, ButtonHeight; + public int X, Y, Width, Height; + public NativeWindow Window; + public Action OnClick; + public string Text; + + static FastColour boxCol = new FastColour( 169, 143, 192 ), shadowCol = new FastColour( 97, 81, 110 ); + public void DrawAt( IDrawer2D drawer, string text, Font font, Anchor horAnchor, + Anchor verAnchor, int width, int height, int x, int y ) { + ButtonWidth = width; ButtonHeight = height; + Width = width + 2; Height = height + 2; // adjust for border size of 2 + CalculateOffset( x, y, horAnchor, verAnchor ); + Redraw( drawer, text, font ); + } + + void CalculateOffset( int x, int y, Anchor horAnchor, Anchor verAnchor ) { + if( horAnchor == Anchor.LeftOrTop ) X = x; + else if( horAnchor == Anchor.Centre ) X = x + Window.Width / 2 - Width / 2; + else if( horAnchor == Anchor.BottomOrRight ) X = x + Window.Width - Width; + + if( verAnchor == Anchor.LeftOrTop ) Y = y; + else if( verAnchor == Anchor.Centre ) Y = y + Window.Height / 2 - Height / 2; + else if( verAnchor == Anchor.BottomOrRight ) Y = y + Window.Height - Height; + } + + public void Redraw( IDrawer2D drawer, string text, Font font ) { + Size size = drawer.MeasureSize( text, font, true ); + int width = ButtonWidth, height = ButtonHeight; + int xOffset = width - size.Width, yOffset = height - size.Height; + + drawer.DrawRoundedRect( shadowCol, 3, X + IDrawer2D.Offset, Y + IDrawer2D.Offset, + width, height ); + drawer.DrawRoundedRect( boxCol, 3, X, Y, width, height ); + + DrawTextArgs args = new DrawTextArgs( text, true ); + args.SkipPartsCheck = true; + drawer.DrawText( font, ref args, + X + 1 + xOffset / 2, Y + 1 + yOffset / 2 ); + } + } +} diff --git a/Launcher2/GameStartData.cs b/Launcher2/GameStartData.cs new file mode 100644 index 000000000..7a6e9b4af --- /dev/null +++ b/Launcher2/GameStartData.cs @@ -0,0 +1,25 @@ +using System; + +namespace Launcher2 { + + public class GameStartData { + + public string Username; + + public string Mppass; + + public string Ip; + + public string Port; + + public GameStartData() { + } + + public GameStartData( string user, string mppass, string ip, string port ) { + Username = user; + Mppass = mppass; + Ip = ip; + Port = port; + } + } +} \ No newline at end of file diff --git a/Launcher2/Launcher2.csproj b/Launcher2/Launcher2.csproj index 98b34a036..403586032 100644 --- a/Launcher2/Launcher2.csproj +++ b/Launcher2/Launcher2.csproj @@ -1,5 +1,5 @@  - + {3E84ACC1-27B4-401B-A359-6AAE4DF6C9B5} Debug @@ -7,8 +7,9 @@ WinExe Launcher2 Launcher2 - v4.0 - Client + v2.0 + + Properties False True @@ -43,15 +44,21 @@ - - 3.5 - + + + + + + + + + @@ -63,5 +70,9 @@ OpenTK + + + + \ No newline at end of file diff --git a/Launcher2/MainScreen.cs b/Launcher2/MainScreen.cs index 656dc31a8..b58a26c3f 100644 --- a/Launcher2/MainScreen.cs +++ b/Launcher2/MainScreen.cs @@ -15,11 +15,41 @@ namespace Launcher2 { public void Init() { Window.Resize += HandleResize; - Window.Mouse.Move += new EventHandler(Window_Mouse_Move); + Window.Mouse.Move += MouseMove; + Window.Mouse.ButtonDown += MouseButtonDown; + + logoFont = new Font( "Times New Roman", 28, FontStyle.Bold ); + logoItalicFont = new Font( "Times New Roman", 28, FontStyle.Italic ); + textFont = new Font( "Arial", 16, FontStyle.Bold ); } - void Window_Mouse_Move(object sender, MouseMoveEventArgs e) { + FastButtonWidget selectedWidget; + void MouseMove( object sender, MouseMoveEventArgs e ) { //System.Diagnostics.Debug.Print( "moved" ); + for( int i = 0; i < widgets.Length; i++ ) { + FastButtonWidget widget = widgets[i]; + if( e.X >= widget.X && e.Y >= widget.Y && + e.X < widget.X + widget.Width && e.Y < widget.Y + widget.Height ) { + if( selectedWidget != widget && selectedWidget != null ) { + using( IDrawer2D drawer = Drawer ) { + drawer.SetBitmap( background ); + selectedWidget.Redraw( Drawer, selectedWidget.Text, textFont ); + FilterButton( selectedWidget.X, selectedWidget.Y, + selectedWidget.Width, selectedWidget.Height, 180 ); + widget.Redraw( Drawer, widget.Text, textFont ); + } + } + selectedWidget = widget; + break; + } + } + } + + void MouseButtonDown( object sender, MouseButtonEventArgs e ) { + if( e.Button != MouseButton.Left ) return; + + if( selectedWidget != null && selectedWidget.OnClick != null ) + selectedWidget.OnClick(); } public void Display() { @@ -33,18 +63,18 @@ namespace Launcher2 { } Bitmap background; + Font textFont, logoFont, logoItalicFont; + static FastColour clearColour = new FastColour( 30, 30, 30 ); + static uint clearColourBGRA = (uint)(new FastColour( 30, 30, 30 ).ToArgb()); public void RecreateBackground() { System.Diagnostics.Debug.Print( "DISPLAY" ); if( background != null ) background.Dispose(); background = new Bitmap( Window.Width, Window.Height ); - Font logoFont = new Font( "Times New Roman", 28, FontStyle.Bold ); - Font logoItalicFont = new Font( "Times New Roman", 28, FontStyle.Italic ); - using( IDrawer2D drawer = Drawer ) { drawer.SetBitmap( background ); - drawer.Clear( Color.FromArgb( 30, 30, 30 ) ); + drawer.Clear( clearColour ); Size size1 = drawer.MeasureSize( "&eClassical", logoItalicFont, true ); Size size2 = drawer.MeasureSize( "&eSharp", logoFont, true ); @@ -64,67 +94,51 @@ namespace Launcher2 { static FastColour boxCol = new FastColour( 169, 143, 192 ), shadowCol = new FastColour( 97, 81, 110 ); void DrawButtons( IDrawer2D drawer ) { widgetIndex = 0; - using( Font font = new Font( "Arial", 16, FontStyle.Bold ) ) { - DrawText( drawer, "Direct connect", font, Anchor.Centre, Anchor.Centre, - buttonWidth, buttonHeight, 0, -100 ); - DrawText( drawer, "ClassiCube.net", font, Anchor.Centre, Anchor.Centre, - buttonWidth, buttonHeight, 0, -50 ); - DrawText( drawer, "Default texture pack", font, Anchor.Centre, Anchor.Centre, - buttonWidth, buttonHeight, 0, 50 ); - - DrawText( drawer, "Singleplayer", font, Anchor.LeftOrTop, Anchor.BottomOrRight, - sideButtonWidth, buttonHeight, 10, -10 ); - DrawText( drawer, "Resume", font, Anchor.BottomOrRight, Anchor.BottomOrRight, - sideButtonWidth, buttonHeight, -10, -10 ); - } + MakeButtonAt( drawer, "Direct connect", Anchor.Centre, Anchor.Centre, + buttonWidth, buttonHeight, 0, -100 ); + MakeButtonAt( drawer, "ClassiCube.net", Anchor.Centre, Anchor.Centre, + buttonWidth, buttonHeight, 0, -50 ); + MakeButtonAt( drawer, "Default texture pack", Anchor.Centre, Anchor.Centre, + buttonWidth, buttonHeight, 0, 50 ); + + MakeButtonAt( drawer, "Singleplayer", Anchor.LeftOrTop, Anchor.BottomOrRight, + sideButtonWidth, buttonHeight, 10, -10 ); + widgets[widgetIndex - 1].OnClick + = () => Program.StartClient( "default.zip" ); + MakeButtonAt( drawer, "Resume", Anchor.BottomOrRight, Anchor.BottomOrRight, + sideButtonWidth, buttonHeight, -10, -10 ); } - Widget[] widgets = new Widget[5]; + FastButtonWidget[] widgets = new FastButtonWidget[5]; int widgetIndex = 0; const int buttonWidth = 220, buttonHeight = 35, sideButtonWidth = 150; - void DrawText( IDrawer2D drawer, string text, Font font, Anchor horAnchor, - Anchor verAnchor, int width, int height, int x, int y ) { - Size textSize = drawer.MeasureSize( text, font, true ); - int xOffset = width - textSize.Width; - int yOffset = height - textSize.Height; - - if( horAnchor == Anchor.Centre ) x = x + Window.Width / 2 - width / 2; - else if( horAnchor == Anchor.BottomOrRight ) x = x + Window.Width - width; - if( verAnchor == Anchor.Centre ) y = y + Window.Height / 2 - height / 2; - else if( verAnchor == Anchor.BottomOrRight ) y = y + Window.Height - height; - - drawer.DrawRoundedRect( shadowCol, 3, x + IDrawer2D.Offset, y + IDrawer2D.Offset, - width, height ); - drawer.DrawRoundedRect( boxCol, 3, x, y, width, height ); - - DrawTextArgs args = new DrawTextArgs( text, true ); - args.SkipPartsCheck = true; - drawer.DrawText( font, ref args, - x + 1 + xOffset / 2, y + 1 + yOffset / 2 ); - Widget widget = new Widget(); - // adjust for border size of 2 - widget.X = x; widget.Y = y; - widget.Width = width + 2; widget.Height = height + 2; - //FilterButton( widget.X, widget.Y, widget.Width, widget.Height, 150 ); + void MakeButtonAt( IDrawer2D drawer, string text, Anchor horAnchor, + Anchor verAnchor, int width, int height, int x, int y ) { + FastButtonWidget widget = new FastButtonWidget(); + widget.Window = Window; + widget.Text = text; + widget.DrawAt( drawer, text, textFont, horAnchor, verAnchor, width, height, x, y ); + FilterButton( widget.X, widget.Y, widget.Width, widget.Height, 180 ); widgets[widgetIndex++] = widget; } - class Widget { - public int X, Y; - public int Width, Height; - public bool Active; - } - void HandleResize( object sender, EventArgs e ) { RecreateBackground(); } + public void Dispose() { + logoFont.Dispose(); + logoItalicFont.Dispose(); + textFont.Dispose(); + } + unsafe void FilterButton( int x, int y, int width, int height, byte scale ) { using( FastBitmap bmp = new FastBitmap( background, true ) ) { for( int yy = y; yy < y + height; yy++ ) { int* row = bmp.GetRowPtr( yy ) + x; for( int xx = 0; xx < width; xx++ ) { uint pixel = (uint)row[xx]; + if( pixel == clearColourBGRA ) continue; uint a = pixel & 0xFF000000; uint r = (pixel >> 16) & 0xFF; uint g = (pixel >> 8) & 0xFF; @@ -138,6 +152,5 @@ namespace Launcher2 { } } } - } } diff --git a/Launcher2/Patcher/Animations.cs b/Launcher2/Patcher/Animations.cs new file mode 100644 index 000000000..783b21fc0 --- /dev/null +++ b/Launcher2/Patcher/Animations.cs @@ -0,0 +1,50 @@ +using System; +using System.Drawing; +using System.IO; + +namespace Launcher2 { + + public partial class ResourceFetcher { + + const string animationsTxt = @"# This file defines the animations used in a texture pack for ClassicalSharp and other supporting applications. +# Each line is in the format: +# - TileX and TileY indicate the coordinates of the tile in terrain.png that +# will be replaced by the animation frames. These range from 0 to 15. (inclusive of 15) +# - FrameX and FrameY indicates the pixel coordinates of the first animation frame in animations.png. +# - Frame Size indicates the size in pixels of an animation frame. +# - Frames count indicates the number of used frames. The first frame is located at +# (FrameX, FrameY), the second one at (FrameX + FrameSize, FrameY) and so on. +# - Tick delay is the number of ticks a frame doesn't change. For instance, a value of 0 +# means that the frame would be changed every tick, while a value of 2 would mean +# 'replace with frame 1, don't change frame, don't change frame, replace with frame 2'. + +# still water +14 0 0 0 16 32 2 +# still lava +14 1 0 16 16 39 2 +# fire +6 2 0 32 16 32 0"; + + unsafe void PatchDefault( byte[] data, int y ) { + // Sadly files in modern are 24 rgb, so we can't use fastbitmap here + using( Bitmap bmp = new Bitmap( new MemoryStream( data ) ) ) { + for( int tile = 0; tile < bmp.Height; tile += 16 ) { + CopyTile( tile, tile, y, bmp ); + } + } + } + + unsafe void PatchCycle( byte[] data, int y ) { + using( Bitmap bmp = new Bitmap( new MemoryStream( data ) ) ) { + int dst = 0; + for( int tile = 0; tile < bmp.Height; tile += 16, dst += 16 ) { + CopyTile( tile, dst, y, bmp ); + } + // Cycle back to first frame. + for( int tile = bmp.Height - 32; tile >= 0; tile -= 16, dst += 16 ) { + CopyTile( tile, dst, y, bmp ); + } + } + } + } +} diff --git a/Launcher2/Patcher/ResourceFetcher.cs b/Launcher2/Patcher/ResourceFetcher.cs new file mode 100644 index 000000000..ff51bfd44 --- /dev/null +++ b/Launcher2/Patcher/ResourceFetcher.cs @@ -0,0 +1,159 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Net; +using ClassicalSharp; +using ClassicalSharp.TexturePack; +using OpenTK; + +namespace Launcher2 { + + public partial class ResourceFetcher { + + const string classicJarUri = "http://s3.amazonaws.com/Minecraft.Download/versions/c0.30_01c/c0.30_01c.jar"; + const string modernJarUri = "http://s3.amazonaws.com/Minecraft.Download/versions/1.6.2/1.6.2.jar"; + const string terrainPatchUri = "http://static.classicube.net/terrain-patch.png"; + static int resourcesCount = 3; + + public void Run( NativeWindow window ) { + using( WebClient client = new GZipWebClient() ) { + WebRequest.DefaultWebProxy = null; + int i = 0; + DownloadData( classicJarUri, client, "classic.jar", window, ref i ); + DownloadData( modernJarUri, client, "1.6.2.jar", window, ref i ); + DownloadData( terrainPatchUri, client, "terrain-patch.png", window, ref i ); + } + + reader = new ZipReader(); + reader.ShouldProcessZipEntry = ShouldProcessZipEntry_Classic; + reader.ProcessZipEntry = ProcessZipEntry_Classic; + + using( FileStream srcClassic = File.OpenRead( "classic.jar" ), + srcModern = File.OpenRead( "1.6.2.jar" ), + dst = new FileStream( "default.zip", FileMode.Create, FileAccess.Write ) ) { + writer = new ZipWriter( dst ); + reader.Extract( srcClassic ); + + // Grab animations and snow + animBitmap = new Bitmap( 1024, 64, PixelFormat.Format32bppArgb ); + reader.ShouldProcessZipEntry = ShouldProcessZipEntry_Modern; + reader.ProcessZipEntry = ProcessZipEntry_Modern; + reader.Extract( srcModern ); + writer.WriteNewImage( animBitmap, "animations.png" ); + writer.WriteNewString( animationsTxt, "animations.txt" ); + animBitmap.Dispose(); + writer.WriteCentralDirectoryRecords(); + } + } + ZipReader reader; + ZipWriter writer; + Bitmap animBitmap; + + bool ShouldProcessZipEntry_Classic( string filename ) { + return filename.StartsWith( "mob" ) || ( filename.IndexOf( '/' ) < 0 ); + } + + void ProcessZipEntry_Classic( string filename, byte[] data, ZipEntry entry ) { + if( writer.entries == null ) + writer.entries = new ZipEntry[reader.entries.Length]; + if( filename != "terrain.png" ) { + writer.WriteZipEntry( entry, data ); + return; + } + + using( Bitmap dstBitmap = new Bitmap( new MemoryStream( data ) ), + maskBitmap = new Bitmap( "terrain-patch.png" ) ) { + PatchImage( dstBitmap, maskBitmap ); + writer.WriteNewImage( dstBitmap, "terrain.png" ); + } + } + + bool ShouldProcessZipEntry_Modern( string filename ) { + return filename.StartsWith( "assets/minecraft/textures" ) && + ( filename == "assets/minecraft/textures/environment/snow.png" || + filename == "assets/minecraft/textures/blocks/water_still.png" || + filename == "assets/minecraft/textures/blocks/lava_still.png" || + filename == "assets/minecraft/textures/blocks/fire_layer_1.png" || + filename == "assets/minecraft/textures/entity/chicken.png" ); + } + + void ProcessZipEntry_Modern( string filename, byte[] data, ZipEntry entry ) { + switch( filename ) { + case "assets/minecraft/textures/environment/snow.png": + entry.Filename = "snow.png"; + writer.WriteZipEntry( entry, data ); + break; + case "assets/minecraft/textures/entity/chicken.png": + entry.Filename = "mob/chicken.png"; + writer.WriteZipEntry( entry, data ); + break; + case "assets/minecraft/textures/blocks/water_still.png": + PatchDefault( data, 0 ); + break; + case "assets/minecraft/textures/blocks/lava_still.png": + PatchCycle( data, 16 ); + break; + case "assets/minecraft/textures/blocks/fire_layer_1.png": + PatchDefault( data, 32 ); + break; + } + } + + unsafe void PatchImage( Bitmap dstBitmap, Bitmap maskBitmap ) { + using( FastBitmap dst = new FastBitmap( dstBitmap, true ), + src = new FastBitmap( maskBitmap, true ) ) { + int size = src.Width, tileSize = size / 16; + + for( int y = 0; y < size; y += tileSize ) { + int* row = src.GetRowPtr( y ); + for( int x = 0; x < size; x += tileSize ) { + if( row[x] != unchecked((int)0x80000000) ) { + FastBitmap.MovePortion( x, y, x, y, src, dst, tileSize ); + } + } + } + } + } + + void CopyTile( int src, int dst, int y, Bitmap bmp ) { + for( int yy = 0; yy < 16; yy++ ) { + for( int xx = 0; xx < 16; xx++ ) { + animBitmap.SetPixel( dst + xx, y + yy, + bmp.GetPixel( xx, src + yy ) ); + } + } + } + + public bool CheckAllResourcesExist() { + return File.Exists( "default.zip" ); + } + + class GZipWebClient : WebClient { + + protected override WebRequest GetWebRequest( Uri address ) { + HttpWebRequest request = (HttpWebRequest)base.GetWebRequest( address ); + request.AutomaticDecompression = DecompressionMethods.GZip; + return request; + } + } + + static bool DownloadData( string uri, WebClient client, string output, + NativeWindow window, ref int i ) { + i++; + if( File.Exists( output ) ) return true; + window.Title = Program.AppName + " - fetching " + output + "(" + i + "/" + resourcesCount + ")"; + + try { + client.DownloadFile( uri, output ); + } catch( WebException ex ) { + //Program.LogException( ex ); + //MessageBox.Show( "Unable to download or save " + output, "Failed to download or save resource", + // MessageBoxButtons.OK, MessageBoxIcon.Error ); + // TODO: show error + return false; + } + return true; + } + } +} diff --git a/Launcher2/Patcher/ZipWriter.cs b/Launcher2/Patcher/ZipWriter.cs new file mode 100644 index 000000000..8b603815c --- /dev/null +++ b/Launcher2/Patcher/ZipWriter.cs @@ -0,0 +1,122 @@ +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Text; +using ClassicalSharp.TexturePack; + +namespace Launcher2 { + + public sealed class ZipWriter { + + BinaryWriter writer; + Stream stream; + public ZipWriter( Stream stream ) { + this.stream = stream; + writer = new BinaryWriter( stream ); + } + + internal ZipEntry[] entries; + internal int entriesCount; + + public void WriteZipEntry( ZipEntry entry, byte[] data ) { + entry.CompressedDataSize = (int)entry.UncompressedDataSize; + entry.LocalHeaderOffset = (int)stream.Position; + entries[entriesCount++] = entry; + WriteLocalFileEntry( entry, data, data.Length ); + } + + public void WriteNewImage( Bitmap bmp, string filename ) { + MemoryStream data = new MemoryStream(); + bmp.Save( data, ImageFormat.Png ); + byte[] buffer = data.GetBuffer(); + WriteNewEntry( filename, buffer, (int)data.Length ); + } + + public void WriteNewString( string text, string filename ) { + byte[] data = Encoding.ASCII.GetBytes( text ); + WriteNewEntry( filename, data, data.Length ); + } + + public void WriteNewEntry( string filename, byte[] data, int dataLength ) { + ZipEntry entry = new ZipEntry(); + entry.UncompressedDataSize = dataLength; + entry.Crc32 = CRC32( data, dataLength ); + entry.CompressedDataSize = dataLength; + entry.LocalHeaderOffset = (int)stream.Position; + + entry.Filename = filename; + entries[entriesCount++] = entry; + WriteLocalFileEntry( entry, data, dataLength ); + } + + public void WriteCentralDirectoryRecords() { + int dirOffset = (int)stream.Position; + for( int i = 0; i < entriesCount; i++ ) { + WriteCentralDirectoryHeaderEntry( entries[i] ); + } + int dirSize = (int)( stream.Position - dirOffset ); + WriteEndOfCentralDirectoryRecord( (ushort)entriesCount, dirSize, dirOffset ); + } + + void WriteLocalFileEntry( ZipEntry entry, byte[] data, int length ) { + writer.Write( 0x04034b50 ); // signature + writer.Write( (ushort)20 ); // version needed + writer.Write( (ushort)0 ); // bitflags + writer.Write( (ushort)0 ); // compression method + writer.Write( 0 ); // last modified + writer.Write( entry.Crc32 ); + writer.Write( entry.CompressedDataSize ); + writer.Write( entry.UncompressedDataSize ); + writer.Write( (ushort)entry.Filename.Length ); + writer.Write( (ushort)0 ); // extra field length + for( int i = 0; i < entry.Filename.Length; i++ ) + writer.Write( (byte)entry.Filename[i] ); + + writer.Write( data, 0, length ); + } + + void WriteCentralDirectoryHeaderEntry( ZipEntry entry ) { + writer.Write( 0x02014b50 ); // signature + writer.Write( (ushort)20 ); // version + writer.Write( (ushort)20 ); // version needed + writer.Write( (ushort)0 ); // bitflags + writer.Write( (ushort)0 ); // compression method + writer.Write( 0 ); // last modified + writer.Write( entry.Crc32 ); + writer.Write( entry.CompressedDataSize ); + writer.Write( entry.UncompressedDataSize ); + + writer.Write( (ushort)entry.Filename.Length ); + writer.Write( (ushort)0 ); // extra field length + writer.Write( (ushort)0 ); // file comment length + writer.Write( (ushort)0 ); // disk number + writer.Write( (ushort)0 ); // internal attributes + writer.Write( 0 ); // external attributes + writer.Write( entry.LocalHeaderOffset ); + for( int i = 0; i < entry.Filename.Length; i++ ) + writer.Write( (byte)entry.Filename[i] ); + } + + void WriteEndOfCentralDirectoryRecord( ushort entries, int centralDirSize, int centralDirOffset ) { + writer.Write( 0x06054b50 ); // signature + writer.Write( (ushort)0 ); // disk number + writer.Write( (ushort)0 ); // disk number of start + writer.Write( entries ); // disk entries + writer.Write( entries ); // total entries + writer.Write( centralDirSize ); + writer.Write( centralDirOffset ); + writer.Write( (ushort)0 ); // comment length + } + + static uint CRC32( byte[] data, int length ) { + uint crc = 0xffffffffU; + for( int i = 0; i < length; i++ ) { + crc ^= data[i]; + for( int j = 0; j < 8; j++ ) + crc = (crc >> 1) ^ (crc & 1) * 0xEDB88320; + } + return crc ^ 0xffffffffU; + } + } +} diff --git a/Launcher2/Program.cs b/Launcher2/Program.cs index 938c95fad..4231e5381 100644 --- a/Launcher2/Program.cs +++ b/Launcher2/Program.cs @@ -2,6 +2,8 @@ using ClassicalSharp; using OpenTK; using OpenTK.Graphics; +using System.IO; +using System.Diagnostics; namespace Launcher2 { @@ -18,15 +20,37 @@ namespace Launcher2 { MainScreen screen = new MainScreen(); screen.Drawer = new GdiPlusDrawer2D( null ); screen.Window = window; - screen.RecreateBackground(); screen.Init(); + screen.RecreateBackground(); while( true ) { window.ProcessEvents(); if( !window.Exists ) break; + screen.Display(); System.Threading.Thread.Sleep( 10 ); } } + + static string missingExeMessage = "Failed to start ClassicalSharp. (classicalsharp.exe was not found)" + + Environment.NewLine + Environment.NewLine + + "This application is only the launcher, it is not the actual client. " + + "Please place the launcher in the same directory as the client (classicalsharp.exe)."; + + public static bool StartClient( string args ) { + Process process = null; + + if( !File.Exists( "ClassicalSharp.exe" ) ) { + // TODO: show message popup + return false; + } + + if( Type.GetType( "Mono.Runtime" ) != null ) { + process = Process.Start( "mono", "\"ClassicalSharp.exe\" " + args ); + } else { + process = Process.Start( "ClassicalSharp.exe", args ); + } + return true; + } } } diff --git a/Launcher2/WebService/ClassiCubeSession.cs b/Launcher2/WebService/ClassiCubeSession.cs new file mode 100644 index 000000000..047edd912 --- /dev/null +++ b/Launcher2/WebService/ClassiCubeSession.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; + +namespace Launcher2 { + + public class ClassicubeSession : GameSession { + + const string classicubeNetUri = "https://www.classicube.net/", + loginUri = "https://www.classicube.net/acc/login", + publicServersUri = "https://www.classicube.net/server/list", + playUri = "https://www.classicube.net/server/play/"; + const string wrongCredentialsMessage = "Login failed"; + const string loggedInAs = @""; + StringComparison ordinal = StringComparison.Ordinal; + + public override void Login( string user, string password ) { + Username = user; + // Step 1: GET csrf token from login page. + var swGet = System.Diagnostics.Stopwatch.StartNew(); + var getResponse = GetHtml( loginUri, classicubeNetUri ); + string token = null; + foreach( string line in getResponse ) { + //Console.WriteLine( line ); + if( line.StartsWith( @" tags + if( ( index = line.IndexOf( " 0 ) { + int nameStart = index + 13; + int nameEnd = line.IndexOf( '"', nameStart ); + string paramName = line.Substring( nameStart, nameEnd - nameStart ); + + // Don't read param value by default so we avoid allocating unnecessary 'value' strings. + if( paramName == "server" ) { + data.Ip = GetParamValue( line, nameEnd ); + } else if( paramName == "port" ) { + data.Port = GetParamValue( line, nameEnd ); + } else if( paramName == "mppass" ) { + data.Mppass = GetParamValue( line, nameEnd ); + } + } + } + return data; + } + + static string GetParamValue( string line, int nameEnd ) { + int valueStart = nameEnd + 9; + int valueEnd = line.IndexOf( '"', valueStart ); + return line.Substring( valueStart, valueEnd - valueStart ); + } + + public override List GetPublicServers() { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var response = GetHtml( publicServersUri, classicubeNetUri ); + List servers = new List(); + int index = -1; + + string hash = null; + string name = null; + string players = null; + string maxPlayers = null; + + foreach( string line in response ) { + if( line.StartsWith( " ", ordinal ) ) { + const int playersStart = 24; + int playersEnd = line.IndexOf( '/', playersStart ); + players = line.Substring( playersStart, playersEnd - playersStart ); + + int maxPlayersStart = playersEnd + 1; + int maxPlayersEnd = line.IndexOf( ']', playersStart ); + maxPlayers = line.Substring( maxPlayersStart, maxPlayersEnd - maxPlayersStart ); + servers.Add( new ServerListEntry( hash, name, players, maxPlayers, "" ) ); + } + } + Log( "cc servers took " + sw.ElapsedMilliseconds ); + sw.Stop(); + return servers; + } + } +} \ No newline at end of file diff --git a/Launcher2/WebService/GameSession.cs b/Launcher2/WebService/GameSession.cs new file mode 100644 index 000000000..b42c720fe --- /dev/null +++ b/Launcher2/WebService/GameSession.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; + +namespace Launcher2 { + + public abstract class GameSession { + + public string Username; + + public virtual void ResetSession() { + Username = null; + cookies = new CookieContainer(); + } + + public abstract void Login( string user, string password ); + + public abstract GameStartData GetConnectInfo( string hash ); + + public abstract List GetPublicServers(); + + CookieContainer cookies = new CookieContainer(); + + protected HttpWebResponse MakeRequest( string uri, string referer, string data ) { + HttpWebRequest request = (HttpWebRequest)WebRequest.Create( uri ); + request.UserAgent = Program.AppName; + request.ReadWriteTimeout = 15 * 1000; + request.Timeout = 15 * 1000; + request.Referer = referer; + request.KeepAlive = true; + request.CookieContainer = cookies; + // On my machine, these reduce minecraft server list download time from 40 seconds to 4. + request.Proxy = null; + request.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + if( data != null ) { + request.Method = "POST"; + request.ContentType = "application/x-www-form-urlencoded; charset=UTF-8;"; + byte[] encodedData = Encoding.UTF8.GetBytes( data ); + request.ContentLength = encodedData.Length; + using( Stream stream = request.GetRequestStream() ) { + stream.Write( encodedData, 0, encodedData.Length ); + } + } + return (HttpWebResponse)request.GetResponse(); + } + + protected IEnumerable GetHtml( string uri, string referer ) { + HttpWebResponse response = MakeRequest( uri, referer, null ); + return GetResponseLines( response ); + } + + protected IEnumerable PostHtml( string uri, string referer, string data ) { + HttpWebResponse response = MakeRequest( uri, referer, data ); + return GetResponseLines( response ); + } + + protected IEnumerable GetResponseLines( HttpWebResponse response ) { + using( Stream stream = response.GetResponseStream() ) { + using( StreamReader reader = new StreamReader( stream ) ) { + string line; + while( ( line = reader.ReadLine() ) != null ) { + yield return line; + } + } + } + } + + protected static void Log( string text ) { + System.Diagnostics.Debug.WriteLine( text ); + } + } +} diff --git a/Launcher2/WebService/ServerListEntry.cs b/Launcher2/WebService/ServerListEntry.cs new file mode 100644 index 000000000..18fe16919 --- /dev/null +++ b/Launcher2/WebService/ServerListEntry.cs @@ -0,0 +1,29 @@ +using System; + +namespace Launcher2 { + + public class ServerListEntry { + + public string Hash; + + /// Name of the server. + public string Name; + + /// Current number of players on the server. + public string Players; + + /// Maximum number of players that can play on the server. + public string MaximumPlayers; + + /// How long the server has been 'alive'. + public string Uptime; + + public ServerListEntry( string hash, string name, string players, string maxPlayers, string uptime ) { + Hash = hash; + Name = name; + Players = players; + MaximumPlayers = maxPlayers; + Uptime = uptime; + } + } +} \ No newline at end of file diff --git a/Launcher2/WebService/WebUtility.cs b/Launcher2/WebService/WebUtility.cs new file mode 100644 index 000000000..74519f097 --- /dev/null +++ b/Launcher2/WebService/WebUtility.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace Launcher2 { + + public static class WebUtility { + + static Dictionary _lookupTable = new Dictionary( 253, StringComparer.Ordinal ) { + { "quot", '\x0022' }, { "amp", '\x0026' }, { "apos", '\x0027' }, { "lt", '\x003C' }, + { "gt", '\x003E' }, { "nbsp", '\x00A0' }, { "iexcl", '\x00A1' }, { "cent", '\x00A2' }, + { "pound", '\x00A3' }, { "curren", '\x00A4' }, { "yen", '\x00A5' }, { "brvbar", '\x00A6' }, + { "sect", '\x00A7' }, { "uml", '\x00A8' }, { "copy", '\x00A9' }, { "ordf", '\x00AA' }, + { "laquo", '\x00AB' }, { "not", '\x00AC' }, { "shy", '\x00AD' }, { "reg", '\x00AE' }, + { "macr", '\x00AF' }, { "deg", '\x00B0' }, { "plusmn", '\x00B1' }, { "sup2", '\x00B2' }, + { "sup3", '\x00B3' }, { "acute", '\x00B4' }, { "micro", '\x00B5' }, { "para", '\x00B6' }, + { "middot", '\x00B7' }, { "cedil", '\x00B8' }, { "sup1", '\x00B9' }, { "ordm", '\x00BA' }, + { "raquo", '\x00BB' }, { "frac14", '\x00BC' }, { "frac12", '\x00BD' }, { "frac34", '\x00BE' }, + { "iquest", '\x00BF' }, { "Agrave", '\x00C0' }, { "Aacute", '\x00C1' }, { "Acirc", '\x00C2' }, + { "Atilde", '\x00C3' }, { "Auml", '\x00C4' }, { "Aring", '\x00C5' }, { "AElig", '\x00C6' }, + { "Ccedil", '\x00C7' }, { "Egrave", '\x00C8' }, { "Eacute", '\x00C9' }, { "Ecirc", '\x00CA' }, + { "Euml", '\x00CB' }, { "Igrave", '\x00CC' }, { "Iacute", '\x00CD' }, { "Icirc", '\x00CE' }, + { "Iuml", '\x00CF' }, { "ETH", '\x00D0' }, { "Ntilde", '\x00D1' }, { "Ograve", '\x00D2' }, + { "Oacute", '\x00D3' }, { "Ocirc", '\x00D4' }, { "Otilde", '\x00D5' }, { "Ouml", '\x00D6' }, + { "times", '\x00D7' }, { "Oslash", '\x00D8' }, { "Ugrave", '\x00D9' }, { "Uacute", '\x00DA' }, + { "Ucirc", '\x00DB' }, { "Uuml", '\x00DC' }, { "Yacute", '\x00DD' }, { "THORN", '\x00DE' }, + { "szlig", '\x00DF' }, { "agrave", '\x00E0' }, { "aacute", '\x00E1' }, { "acirc", '\x00E2' }, + { "atilde", '\x00E3' }, { "auml", '\x00E4' }, { "aring", '\x00E5' }, { "aelig", '\x00E6' }, + { "ccedil", '\x00E7' }, { "egrave", '\x00E8' }, { "eacute", '\x00E9' }, { "ecirc", '\x00EA' }, + { "euml", '\x00EB' }, { "igrave", '\x00EC' }, { "iacute", '\x00ED' }, { "icirc", '\x00EE' }, + { "iuml", '\x00EF' }, { "eth", '\x00F0' }, { "ntilde", '\x00F1' }, { "ograve", '\x00F2' }, + { "oacute", '\x00F3' }, { "ocirc", '\x00F4' }, { "otilde", '\x00F5' }, { "ouml", '\x00F6' }, + { "divide", '\x00F7' }, { "oslash", '\x00F8' }, { "ugrave", '\x00F9' }, { "uacute", '\x00FA' }, + { "ucirc", '\x00FB' }, { "uuml", '\x00FC' }, { "yacute", '\x00FD' }, { "thorn", '\x00FE' }, + { "yuml", '\x00FF' }, { "OElig", '\x0152' }, { "oelig", '\x0153' }, { "Scaron", '\x0160' }, + { "scaron", '\x0161' }, { "Yuml", '\x0178' }, { "fnof", '\x0192' }, { "circ", '\x02C6' }, + { "tilde", '\x02DC' }, { "Alpha", '\x0391' }, { "Beta", '\x0392' }, { "Gamma", '\x0393' }, + { "Delta", '\x0394' }, { "Epsilon", '\x0395' }, { "Zeta", '\x0396' }, { "Eta", '\x0397' }, + { "Theta", '\x0398' }, { "Iota", '\x0399' }, { "Kappa", '\x039A' }, { "Lambda", '\x039B' }, + { "Mu", '\x039C' }, { "Nu", '\x039D' }, { "Xi", '\x039E' }, { "Omicron", '\x039F' }, + { "Pi", '\x03A0' }, { "Rho", '\x03A1' }, { "Sigma", '\x03A3' }, { "Tau", '\x03A4' }, + { "Upsilon", '\x03A5' }, { "Phi", '\x03A6' }, { "Chi", '\x03A7' }, { "Psi", '\x03A8' }, + { "Omega", '\x03A9' }, { "alpha", '\x03B1' }, { "beta", '\x03B2' }, { "gamma", '\x03B3' }, + { "delta", '\x03B4' }, { "epsilon", '\x03B5' }, { "zeta", '\x03B6' }, { "eta", '\x03B7' }, + { "theta", '\x03B8' }, { "iota", '\x03B9' }, { "kappa", '\x03BA' }, { "lambda", '\x03BB' }, + { "mu", '\x03BC' }, { "nu", '\x03BD' }, { "xi", '\x03BE' }, { "omicron", '\x03BF' }, + { "pi", '\x03C0' }, { "rho", '\x03C1' }, { "sigmaf", '\x03C2' }, { "sigma", '\x03C3' }, + { "tau", '\x03C4' }, { "upsilon", '\x03C5' }, { "phi", '\x03C6' }, { "chi", '\x03C7' }, + { "psi", '\x03C8' }, { "omega", '\x03C9' }, { "thetasym", '\x03D1' }, { "upsih", '\x03D2' }, + { "piv", '\x03D6' }, { "ensp", '\x2002' }, { "emsp", '\x2003' }, { "thinsp", '\x2009' }, + { "zwnj", '\x200C' }, { "zwj", '\x200D' }, { "lrm", '\x200E' }, { "rlm", '\x200F' }, + { "ndash", '\x2013' }, { "mdash", '\x2014' }, { "lsquo", '\x2018' }, { "rsquo", '\x2019' }, + { "sbquo", '\x201A' }, { "ldquo", '\x201C' }, { "rdquo", '\x201D' }, { "bdquo", '\x201E' }, + { "dagger", '\x2020' }, { "Dagger", '\x2021' }, { "bull", '\x2022' }, { "hellip", '\x2026' }, + { "permil", '\x2030' }, { "prime", '\x2032' }, { "Prime", '\x2033' }, { "lsaquo", '\x2039' }, + { "rsaquo", '\x203A' }, { "oline", '\x203E' }, { "frasl", '\x2044' }, { "euro", '\x20AC' }, + { "image", '\x2111' }, { "weierp", '\x2118' }, { "real", '\x211C' }, { "trade", '\x2122' }, + { "alefsym", '\x2135' }, { "larr", '\x2190' }, { "uarr", '\x2191' }, { "rarr", '\x2192' }, + { "darr", '\x2193' }, { "harr", '\x2194' }, { "crarr", '\x21B5' }, { "lArr", '\x21D0' }, + { "uArr", '\x21D1' }, { "rArr", '\x21D2' }, { "dArr", '\x21D3' }, { "hArr", '\x21D4' }, + { "forall", '\x2200' }, { "part", '\x2202' }, { "exist", '\x2203' }, { "empty", '\x2205' }, + { "nabla", '\x2207' }, { "isin", '\x2208' }, { "notin", '\x2209' }, { "ni", '\x220B' }, + { "prod", '\x220F' }, { "sum", '\x2211' }, { "minus", '\x2212' }, { "lowast", '\x2217' }, + { "radic", '\x221A' }, { "prop", '\x221D' }, { "infin", '\x221E' }, { "ang", '\x2220' }, + { "and", '\x2227' }, { "or", '\x2228' }, { "cap", '\x2229' }, { "cup", '\x222A' }, + { "int", '\x222B' }, { "there4", '\x2234' }, { "sim", '\x223C' }, { "cong", '\x2245' }, + { "asymp", '\x2248' }, { "ne", '\x2260' }, { "equiv", '\x2261' }, { "le", '\x2264' }, + { "ge", '\x2265' }, { "sub", '\x2282' }, { "sup", '\x2283' }, { "nsub", '\x2284' }, + { "sube", '\x2286' }, { "supe", '\x2287' }, { "oplus", '\x2295' }, { "otimes", '\x2297' }, + { "perp", '\x22A5' }, { "sdot", '\x22C5' }, { "lceil", '\x2308' }, { "rceil", '\x2309' }, + { "lfloor", '\x230A' }, { "rfloor", '\x230B' }, { "lang", '\x2329' }, { "rang", '\x232A' }, + { "loz", '\x25CA' }, { "spades", '\x2660' }, { "clubs", '\x2663' }, { "hearts", '\x2665' }, + { "diams", '\x2666' }, + }; + + public static string HtmlDecode( string value ) { + value = value.Replace( "hellip;", "\x2026" ); // minecraft.net doesn't escape this at the end properly. + if( String.IsNullOrEmpty( value ) || value.IndexOf( '&' ) < 0 ) { + return value; + } + + StringBuilder sb = new StringBuilder(); + WebUtility.HtmlDecode( value, sb ); + return sb.ToString(); + } + + static void HtmlDecode( string value, StringBuilder output ) { + for( int i = 0; i < value.Length; i++ ) { + char token = value[i]; + if( token != '&' ) { + output.Append( token ); + continue; + } + + int entityEnd = value.IndexOf( ';', i + 1 ); + if( entityEnd <= 0 ) { + output.Append( token ); + continue; + } + + string entity = value.Substring( i + 1, entityEnd - i - 1 ); + if( entity.Length > 1 && entity[0] == '#' ) { + ushort encodedNumber; + if( entity[1] == 'x' || entity[1] == 'X' ) { + ushort.TryParse( entity.Substring( 2 ), NumberStyles.AllowHexSpecifier, NumberFormatInfo.InvariantInfo, out encodedNumber ); + } else { + ushort.TryParse( entity.Substring( 1 ), NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out encodedNumber ); + } + if( encodedNumber != 0 ) { + output.Append( (char)encodedNumber ); + i = entityEnd; + } + } else { + i = entityEnd; + char decodedEntity; + if( _lookupTable.TryGetValue( entity, out decodedEntity ) ) { + output.Append( decodedEntity ); + } else { // Invalid token. + output.Append( '&' ); + output.Append( entity ); + output.Append( ';' ); + } + } + } + } + } +}