Click here to Skip to main content
15,879,239 members
Articles / General Programming / Debugging

Visual Studio Loader With Arguments

Rate me:
Please Sign up or sign in to vote.
5.00/5 (6 votes)
24 Oct 2018CPOL9 min read 10.4K   70   4   6
Visual Studio loader with arguments

Introduction

Have you ever had the need to start Visual Studio from a piece of code and the code you needed to debug (the reason why you open Visual Studio instance in the first place) needs command line arguments. The case is that at the moment, devenv.exe does not let you pass arguments to your debug process. Here is where the trouble at my job began.

We needed a solution to be able to load Visual Studio but with supplied arguments. Else, we wouldn't be able to debug the process easily unless we also would run the start from out of debug to spot the current arguments and place them in the other solution by hand.

Route to Solution

To find a way where we would be able to receive arguments, several options were possible:

  • Place them in the registry and read them out, which meant that each debug project needed its own settings and would always need work per project.
  • Place them in the project.user file which actually offers a way to pass arguments towards your project. One of the problems with this method is, in case there are several possible startup objects within a solution, it's nearly impossible to know which to use unless you unravel the magic of a suo file.
  • Find a way to tell the debug project a number in any way.

AppDomainManager vshost Style

When I was looking around in debug to see if we could find any loophole, we noticed that through reflection if loaded through vshost, the app domain manager would have an id of the Visual Studio it was controlled by.

This gave us the thought to see if we could place anything on the starting commandline of devenv itself. It turned out that after /run solution/project, the arguments were ignored. At least so far as I could tell. This gave me the room I needed to be able to spoof something towards the debug process.

WindowBridge

Because of several reasons:

  • Not shortening the allowed characters on a commandline
  • To not hit the possibility a part of the added arguments were picked up by Visual Studio anyways

I decided to use an inbetweener. I created WindowBridge, a standalone executable which had only 1 purpose. To pass through arguments and release them afterwards. The WindowBridge works with an unshown window that accepts arguments and supplies an id number for it.

The Trio

To make work with the window bridge easier, I wanted a helper project that could place an argumentsline and receive its id and a helper project that could determine it needed to load an argumentsline from windowbridge and offers this as close as the normal commandline would be.

The helper project that places the argumentsline has been given a secondary purpose. To help in making the Visual Studio environment start easy. Through the versionnumbering inside the solutionfiles, it determines which version of Visual Studio to use (For Visual Studio 2017 and 2019, this is an educated guess, simply because if have none of both installed at the moment). This principle uses the information to load the Visual Studio it is meant for, or if not present, detects if there is a minimal Visual Studio version specified and seeks from new to old if any compatible version is present and starts that one. Also, the start routine has the possibility to wait for the end of Visual Studio which in case of usage will start devenv with /runexit. This result is that when the debug process is stopped, the Visual Studio environment is closed and the starting process knows it has ended.

The Works

In this section, I'll highlight parts of the code and explain why they are as they are.

Project WindowBridge

C#
using System;
using System.Windows.Forms;

namespace WindowBridge
{
    static class Program
    {
        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            if (WindowBridgeFinder.GetWindowBridge() == IntPtr.Zero)//isolate our version
            {
                using (Bridge OurBridge = new Bridge())
                {
                    Application.Run();
                }
            }
        }
    }
}

public class Bridge : NativeWindow, IDisposable
{
 ...
    private List<uint>                              UniqueId    = new List<uint>();
    private List<KeyValuePair<uint, StringThingy>>  Message     = 
                                             new List<KeyValuePair<uint, StringThingy>>();
}

The WindowBridgeFinder, which I'll explain below, guards us for running multiple instance. With running the Bridge from out of a using block where inside the application is run, the dispose will happen when the window receives the WM_DESTROY windows message.

With making the bridge a NativeWindow, you can easily made a listening window without the overload a WindowForm gets. The UniqueId list keeps track of what ids have been given out. The Message list keeps track of which arguments belong to which uniqueid.

If you look inside the window procedure of the Bridge, you shall see no pointers are used on the message bus. The characters are communicated one by one, this to keep out of the troubles of interprocess memory. Because the order of window messages are not guaranteed, the characters are communicated by position.

WindowBridgefinder Routine, Part of All 3 Projects

C#
using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime.InteropServices;
using System.Diagnostics;
using static WindowBridge.WindowMessages;

namespace WindowBridge
{
    /// <summary>
    /// Finds the WindowBridge
    /// </summary>
    internal static class WindowBridgeFinder
    {
        private delegate bool EnumThreadDelegate(IntPtr hWnd, IntPtr lParam);

        [DllImport("user32.dll")]
        private static extern bool EnumThreadWindows
                   (int dwThreadId, EnumThreadDelegate lpfn, IntPtr lParm);

        [DllImport("user32.dll", EntryPoint = "GetClassName", CharSet = CharSet.Auto)]
        private static extern int GetClassName(IntPtr hwnd, StringBuilder lpClassName, int nMaxCount);

        [DllImport("user32.dll", CharSet = CharSet.Auto, EntryPoint = "SendMessage")]
        private static extern IntPtr SendMessage(IntPtr hwnd, int wMsg, IntPtr wParam, IntPtr lParam);
        private const int WM_USER = 0x400;

        /// <summary>
        /// retrieves all window(top level) handles of a process
        /// </summary>
        /// <param name="ToRetreiveItFor">The process to get this information for</param>
        /// <returns>The found window handles</returns>
        private static List<IntPtr> GetProcessWindowHandles(Process ToRetreiveItFor)
        {
            List<IntPtr> handles = new List<IntPtr>();

            foreach (ProcessThread thread in ToRetreiveItFor.Threads)
            {
                EnumThreadWindows(thread.Id, (hWnd, lParam) =>
                {
                    handles.Add(hWnd);
                    return true;
                }, IntPtr.Zero);
            }

            return handles;
        }

        /// <summary>
        /// Retrieves the class name of a window
        /// </summary>
        /// <param name="Handle">The hWnd to retrieve the class name for</param>
        /// <returns>The retrieved classname</returns>
        private static string GetClassName(IntPtr Handle)
        {
            string          Result      = null;
            int             WantedSize  = 1000;
            StringBuilder   touse       = new StringBuilder("", WantedSize + 5);
            int             Returned    = GetClassName(Handle, touse, WantedSize + 2);

            if (Returned > 0)
            {
                Result = touse.ToString();
            }

            return Result;
        }
        
        /// <summary>
        /// Retrieve the hWnd of the WindowBridge
        /// </summary>
        /// <returns>The hWnd on succes, IntPtr.Zero otherwise</returns>
        public static IntPtr GetWindowBridge()
        {
            IntPtr      Result  = IntPtr.Zero;
            Process[]   Listing = Process.GetProcesses();

            foreach (Process Current in Listing)
            {
                if (Current.ProcessName.StartsWith("WindowBridge"))
                {
                    List<IntPtr> Handles = GetProcessWindowHandles(Current);

                    foreach (IntPtr ToCheck in Handles)
                    {
                        string TheName = GetClassName(ToCheck);

                        bool StartsWith = false;

                        if (!ReferenceEquals(TheName, null))
                        {
                            StartsWith = TheName.StartsWith("WindowsForms");
                        }

                        if (StartsWith && TheName.Contains(WindowBridgeClassPart))
                        {
                            Result = ToCheck;
                            break;
                        }
                    }
                }

                if (Result != IntPtr.Zero)
                {
                    break;
                }
            }

            return Result;
        }
    }
}

The GetWindowBridge function is a bit tricky. We need to find that process of the WindowBridge and then need to find its listening window. We start with retrieving all processes. Within the process, we seek a process starting with WindowBridge we then will retrieve the top window handles of the process and check if any of it meets the window we expect (having a classname starting with WindowsForms and contain the word message, as we're based on the message class). If all requirements have been found, we stop the seeking process and we know the WindowBridge is up and running and which window handle to use to communicate with it.

Visual Studio Loader

C#
public static bool RunDevEnv(string SolutionFile, string Arguments, bool WaitForExit){}        

The Visual Studio loader contains 1 exposed function, RunDevEnv for which the parameter Arguments and WaitForExit have been made optional through overloading.

The solutionfile parameter contains the solution you want to load.

The arguments parameter contains the commandline you want to send. Take into account that also no argument is an argument. In the usage in the debug process, arguments can been set in the project properties. The reason for this also is that empty commandlines are being communicated.

The WaitForExit parameter sets waiting for the end of the to be debugged process active or not.

The return value tells whether the execution of the Visual Studio environment succeeded or not. This will even be tried when WindowBridge is not loaded. Which in case will result in not being able to retrieve the commandline on the debug process side.

C#
public class Loader
{

...

        /// <summary>
        /// Loads all info about visual studio's present on this platform
        /// </summary>
        static Loader()
        {
...
            StudioVersionInfo       = new Dictionary<int, StudioVersionDescriptor>()
            {
                { 8,    new StudioVersionDescriptor("Version_9.00",     "",
                "# Visual Studio 2005")}, //<-- just a gamble this 1 could be off!
                { 9,    new StudioVersionDescriptor("Version_10.00",    "",
                "# Visual Studio 2008")},
                { 10,   new StudioVersionDescriptor("Version_11.00",    "",
                "# Visual Studio 2010")},
                { 11,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_11", "# Visual Studio 12")},
                { 12,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_12", "# Visual Studio 13")},
                { 14,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_14", "# Visual Studio 15")},
                { 15,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_15", "# Visual Studio 17")},
                { 16,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_16", "# Visual Studio 19")},
                { 17,   new StudioVersionDescriptor("Version_12.00",
                "StudioVersion_17", "# Visual Studio 21")}
            };

            KnownRegistryLocations = new string[][]
            {
                new string[]
                {
                    @"CLSID\{FE10D39B-A7F1-412c-83BA-D00788532ABB}\LocalServer32",
                    @"Wow6432Node\CLSID\{1B2EEDD6-C203-4d04-BD59-78906E3E8AAB}\LocalServer32",
                    @"Wow6432Node\CLSID\{BA018599-1DB3-44f9-83B4-461454C84BF8}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.9.0\shell\Open\Command",
                    @"CLSID\{1BD51F8C-8CFC-4708-A88D-5690DE4D5C16}\LocalServer32",
                    @"Wow6432Node\CLSID\{1A5AC6AE-7B95-478C-B422-0E994FD727D6}\LocalServer32",
                    @"Wow6432Node\CLSID\{8B10A141-87EE-4A0F-823F-D79F5FF7B10A}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.10.0\shell\Open\Command",
                    @"VisualStudio.sln.10.0\shell\Open\command",
                    @"Wow6432Node\CLSID\{656D8328-93F5-41a7-A48C-B42858161F25}\LocalServer32",
                    @"Wow6432Node\CLSID\{68681A5C-C22A-421d-B68B-5BA9D01F35C5}\LocalServer32",
                    @"Wow6432Node\CLSID\{6F5BF5E0-D729-46dd-891C-167FE3851574}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.11.0\shell\Open\Command",
                    @"VisualStudio.sln.11.0\shell\Open\command",
                    @"Wow6432Node\CLSID\{059618E6-4639-4D1A-A248-1384E368D5C3}\LocalServer32",
                    @"Wow6432Node\CLSID\{7751A556-096C-44B5-B60D-4CC78885F0E5}\LocalServer32",
                    @"Wow6432Node\CLSID\{EB1425FE-3641-47AB-9484-32B62FC8B0B0}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.12.0\shell\Open\Command",
                    @"VisualStudio.sln.12.0\shell\Open\command",
                    @"Wow6432Node\CLSID\{02CD4067-3D8F-4F9E-957F-F273804560C5}\LocalServer32",
                    @"Wow6432Node\CLSID\{3C0D7ACB-790B-4437-8DD2-815CA17C474D}\LocalServer32",
                    @"Wow6432Node\CLSID\{48AE9D34-2FE7-48A7-9D8A-A65534E3C20C}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.14.0\shell\Open\Command",
                    @"VisualStudio.sln.14.0\shell\Open\command",
                    @"Wow6432Node\CLSID\{31F45B04-7198-45ED-A13F-F224A4A1686A}\LocalServer32",
                    @"Wow6432Node\CLSID\{A2FA2136-EB44-4D10-A1D3-6FE1D63A7C05}\LocalServer32",
                    @"Wow6432Node\CLSID\{CACE29C3-10A7-4B66-A8CA-82C1ECEC1FA3}\LocalServer32",
                } ,
                new string[]
                {
                    @"VisualStudio.accessor.X.0\shell\Open\Command",
                    @"VisualStudio.sln.X.0\shell\Open\command",
                }
            };
            #endregion InitMemory

            for (int Counter = 0; Counter <= Studio20XX; Counter++)
            {
                bool Alter = (Counter == Studio20XX);
                int Loop = Alter ? 3 : 1;
                
                for (int ExtraLoop = 0; ExtraLoop < Loop; ExtraLoop++)//dirty trick 
                                                                      //for finding 'guesses'
                {
                    string[] Use = KnownRegistryLocations[Counter];

                    if (Alter) 
                    {
                        string[] ToCopy = KnownRegistryLocations[Counter];
                        Use = new string[ToCopy.Length];

                        for (int SubCounter = 0; SubCounter < ToCopy.Length; SubCounter++)
                        {
                            Use[SubCounter] = ToCopy[SubCounter];
                        }

                        for (int ReplaceCounter = 0; ReplaceCounter < Use.Length; ReplaceCounter++) 
                        {
                            Use[ReplaceCounter] = 
                            Use[ReplaceCounter].Replace(".X.", "." + (15 + ExtraLoop).ToString() + ".");
                        }
                    }

                    Discover(Use, (string ToCheck) =>
                    {
                        bool Found = false;
                        FileVersionInfo Info = 
                        FileVersionInfo.GetVersionInfo(ToCheck); //We want the user 
                         //to get bothered with no access dialog if an error occur so don't try catch it.

                        if (!ReferenceEquals(Info, null))
                        {
                            StudioVersionDescriptor BelongsTo;

                            if (StudioVersionInfo.TryGetValue(Info.FileMajorPart, out BelongsTo))
                            {
                                BelongsTo.Location = ToCheck;
                                FoundDevelopers.Add(Info.FileMajorPart, BelongsTo);
                                Found = true;
                            }
                            else
                            {
                                Debug.WriteLine(ToCheck + " 
                                                contains an unknown version if Visual Studio, 
                                                this program might be out of date.");
                            }
                        }

                        return Found;
                    });
                }
            }

            if (!ReferenceEquals(FoundDevelopers, null) && (FoundDevelopers.Count > 0))
            {
                Dictionary<int, StudioVersionDescriptor>.ValueCollection Values = 
                                                             FoundDevelopers.Values;
                StudioVersionDescriptor[] Temp = new StudioVersionDescriptor[Values.Count];
                Values.CopyTo(Temp, 0);
                LastKnownStudioVersion = Temp[Temp.Length - 1];
            }
        }
...
}

...

}

In the static constructor of the Loader class, a lot is being prepared to be able to select the correct version of Visual Studio. StudioVersionInfo contains the known version ids together with the known solution file tags (guessed in case of 2017 and 2019). KnownRegistryLocations contain what its name already suggest. Registry keys we know of Visual Studio uses. By this information, we've the possibility to determine its current installed location for the several versions, again 2017 and 2019 are guessed. I only work with local_machine/classroot keys so that we're not troubled with data not present because a different user has installed it and this user has not used it yet.

LastKnownStudioVersion will contain the highest Visual Studio version we found. The version which will be used if for some reason a version to use has not been able to be determined.

C#
 internal static class ProcessSln
 {
     public static bool Process(string solutionFile, out string Version,
         out string SubVersion, out string MinimumVisualStudioVersion, out string[] Projects)
     {...}
}

The ProcessSln class contains the actual logic to read out the contents of the solutionfile and retrieve the information we want from it. It will retrieve version, subversion and the MinimumVisualStudioVersion. It also results the found projects but at this time in this project, this is not further used. (I wrote this class actually for a buildnumber updater routine for our bitten on trac).

When a project file has been supplied instead of a solution file, the Loader.cs will try to find a solution file where the project is a part of, to through that way determine the Visual Studio needed. It will look through the directory of the project file and one directory upper in the hierarchy.

CommandLineLoader

The commandline loader is responsible for determining which commandline to use and supply this in one liners towards its users. To be able to determine which commandline to use it first has to determine whether we're under Visual Studio debug or not.

C#
    /// <summary>
    /// Helps in retrieving the command line from normal route or Window Bridge route
    /// </summary>
    public static class CommandLine
    {
...
        private static readonly BindingFlags GetAll;
        private static readonly int DevEnvId;

        static CommandLine()
        {
            GetAll = BindingFlags.CreateInstance |
                     BindingFlags.FlattenHierarchy |
                     BindingFlags.GetField |
                     BindingFlags.Instance |
                     BindingFlags.InvokeMethod |
                     BindingFlags.NonPublic |
                     BindingFlags.Public |
                     BindingFlags.SetField |
                     BindingFlags.GetProperty |
                     BindingFlags.SetProperty |
                     BindingFlags.Static;

            DevEnvId = GetDevenv();
        }

        /// <summary>
        /// Determines if we've a visual studio whom has loaded us
        /// </summary>
        /// <returns>It's processid if any, 0 otherwise</returns>
        private static int GetDevenv()
        {
            int DevenvId = 0;

            AppDomain           Current             = AppDomain.CurrentDomain;
            AppDomainManager    Manager             = null;
            FieldInfo           m_hpListenerField   = null;
            object              m_hpListener        = null;
            Process             Process             = null;
            FieldInfo           m_procVSField       = null;

            if (!ReferenceEquals(Current, null))
            {
                Manager = Current.DomainManager;
            }

            if (!ReferenceEquals(Manager, null))
            {
                m_hpListenerField = (Manager.GetType().GetField("m_hpListener", GetAll));
            }

            if (!ReferenceEquals(m_hpListenerField, null))
            {
                m_hpListener = m_hpListenerField.GetValue(Manager);
            }

            if (!ReferenceEquals(m_hpListener, null))
            {
                m_procVSField = (m_hpListener.GetType()).GetField("m_procVS", GetAll);
            }

            if (!ReferenceEquals(m_procVSField, null))
            {
                Process = m_procVSField.GetValue(m_hpListener) as Process;
            }

            if (!ReferenceEquals(Process, null)) 
            {
                DevenvId = Process.Id;
            }
            
            return DevenvId;
        }
...
    }   

GetDevEnv is the function that determines the presence of Visual Studio. It does this by trying to retrieve its process id from the special app domain manager which is present if it is loaded through vshost. This route I've tested under 2005,2008,2010,2012,2013,2015. Only under 2008, I found the route not to work, but it could be that I had to save and compile that project first. Only if all steps are completed successfully and we've obtained the process id of the Visual Studio environment responsible for debugging our process, we'll say we found it.

C#
        /// <summary>
        /// Retrieves the arguments with aid from the visual studio commandline 
        /// to get the id needed to do so
        /// </summary>
        /// <param name="DevEnvProcessId">Id to the process of our visual studio instance.</param>
        /// <param name="WithExecutable">
        /// Do we need to prefix the commandline with or without executable 
        /// on the line (gui or console)</param>
        /// <param name="HadId">Did we find any arguments</param>
        /// <returns>The found command line to use</returns>
        private static string GetDevEnvArguments
                     (int DevEnvProcessId, bool WithExecutable, ref bool HadId)
        {
            string Result = string.Empty;

            ManagementObjectSearcher commandlineSearcher =
                new ManagementObjectSearcher
                (
                    "SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + 
                                                        DevEnvProcessId.ToString()
                );

            String CommandLine = "";
            bool Added = false;

            foreach (ManagementObject commandlineObject in commandlineSearcher.Get())
            {
                if (!Added)
                {
                    Added = true;
                }
                else
                {
                    CommandLine += " ";
                }

                CommandLine += commandlineObject["CommandLine"] as string;
            }

            if (!string.IsNullOrWhiteSpace(CommandLine))
            {
                int pos = CommandLine.LastIndexOf(" ");

                if (pos >= 0)
                {
                    string Number = CommandLine.Substring(pos).TrimEnd();
                    int ArgumentsId;

                    if (int.TryParse(Number, out ArgumentsId))
                    {
                        HadId = true;
                        string DevArguments = Bridge.GetArguments(ArgumentsId);

                        if (WithExecutable)
                        {
                            string EnvCmd = Environment.CommandLine;
                            int Pos = EnvCmd.IndexOf(".exe", StringComparison.OrdinalIgnoreCase);

                            if (Pos >= 0)
                            {
                                DevArguments = EnvCmd.Substring(0, Pos + 4) + " " + DevArguments;
                            }
                        }

                        Result = DevArguments;
                    }
                }
            }

            return Result;
        }

With the found process id, the argument line meant for the debug process is retrieved.

First through WMI, the commandline of the Visual Studio instance is retrieved. When this was successful, the last space on the line is seeked. If we've spoofed a number on it, that will be its location. We retrieve the last part of the line and try to convert it to an integer. If this succeeds, we ask the WindowBridge the commandline belonging to the found identifier. If the identifier was valid, we know we have the commandline meant for our debug process. Depending on whether the retrieval is needed for the main(string[]), StartUpEventArgs or main(), we need to stick the executable name in front or not. WithExecutable helps us do this. When all has been glued together, we finally have the commandline to use.

C#
    public static class CommandLine
    {
...
        /// <summary>
        /// Retrieves the command line from the window bridge if started from out dev environment.
        /// If not started through window bridge it will return the supplied input arguments on current
        /// </summary>
        /// <param name="Current">The real args array from main</param>
        /// <returns>The found arguments array to use</returns>
        public static string[] GetCommandLine(string[] Current)

        /// <summary>
        /// Retrieves the command line from the window bridge if started from out dev environment.
        /// If not started through window bridge 
        /// it will return the normal System.Environment.CommandLine
        /// </summary>
        /// <returns>The found command line</returns>
        public static string GetCommandLine()

        /// <summary>
        /// Retrieves the command line from the window bridge if started from out dev environment.
        /// If not started through window bridge 
        /// it will return the normal System.Environment.CommandLine
        /// </summary>
        /// <returns>The found command line in a startupevent argument</returns>
        public static void GetStartupEventArguments(ref StartupEventArgs ToUpdate)
...
    }

The public CommandLine class has three different functions, each meant for a different scenario.

The string[] GetCommandLine(string[] Current) is meant for direct usage after main(string[] args):

C#
args = WindowBridge.CommandLine.GetCommandLine(Args);

The string GetCommandLine() is meant as replacement for the System.Environment.Commandline function, the placed commandline will be cached.

The void GetStartupEventArguments(ref StartupEventArgs ToUpdate) is meant for the overload of Application.OnStartup in the PresentationFramework.

C#
...

protected override void OnStartup(StartupEventArgs e)
{
    WindowBridge.CommandLine.GetStartupEventArguments(ref e);
}
...

The StartupEventArgs _args variable is updated through reflection. The class itself stops retrieving new information once the argument has contents which solve the possible issue of losing our work.

Sum Up

Putting it all together, we've a Visual Studio loader that allows us to easily start a debugging process for a specified solution (or project) file. With the running of WindowBridge and using CommandLineLoader inside the code of the debug process, we get the possibility to run a debugging process with supplying command line arguments. The amount of change needed in source code for the debug processes are minimized to one liners.

As long as the startup project has been saved in the solution, this methodology works for any possible startupobject in a solution, after initial preparation, codes will never change nor shall its user file for passing arguments.

1 Loader, 1 Bridge, 1 Data retriever.

Points of Interest

By creating this loader, I refreshed my old knowledge of the windowproc and window classes, I played a bit once more with WMI and looked more closely to how commandline arguments actually are broken into pieces before we can use it as Args[]. I've updated my knowledge on what a solution file contains but especially does not contain, as for example the project to start.

History

  • V1.0: Initial release

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Netherlands Netherlands
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
Generalsome pictures will help more... Pin
Southmountain24-May-19 7:01
Southmountain24-May-19 7:01 
QuestionDon't know whom did the red coloring, but thank u :) Pin
Mark Kruger24-Oct-18 4:27
Mark Kruger24-Oct-18 4:27 
it helps reading it.

QuestionYou changed one 'u' ... Pin
Richard MacCutchan24-Oct-18 2:01
mveRichard MacCutchan24-Oct-18 2:01 
AnswerMessage Closed Pin
24-Oct-18 3:10
Mark Kruger24-Oct-18 3:10 
AnswerRe: You changed one 'u' ... Pin
Mark Kruger24-Oct-18 3:41
Mark Kruger24-Oct-18 3:41 
GeneralRe: You changed one 'u' ... Pin
Richard MacCutchan24-Oct-18 4:14
mveRichard MacCutchan24-Oct-18 4:14 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.