Old 10-04-2013, 12:50 PM   #1
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default Karbo: Review

Hey Karbo,

Can you look over this code and tell me where and how it might be improved? You know me, vb brute force guy who (too often) uses what I know instead of something better that I don't know. It won't all fit in one post (too long) so I'll split it up.

Basically what I'm doing is writing fader and pan snapshots like what Reaper has, for S1. It's really limited of course but still, better than nothing. What I'm looking for in particular here is - any and all - possibilities to replace, get out from under, any of those Win 32 API calls with native VB.net functions. It's basically just running through all of the fader and pan text fields in the console, capturing all of that string data, storing it to 3 lists / slots, and recalling it and putting it back later the same way. It's only 300 lines so not a lot of code.

It's working pretty well so I may adapt it to some other win apps that don't have anything like Reaper's snapshots... but I'm also sure my code isn't best case.

Thanks in advance VB Master.


Code:
Option Explicit On
Public Class Main

    '////////////// ---  Win 32 API DECLARATIONS --- ////////////////////////////////////////
    Private Declare Function SendMessage Lib "user32.dll" Alias "SendMessageA" _
        (ByVal hWnd As IntPtr, ByVal wMsg As Integer, ByVal wParam As IntPtr, ByVal lParam As IntPtr) As IntPtr
    Private Declare Function SetForegroundWindow Lib "user32" (ByVal hwnd As IntPtr) As IntPtr
    Private Declare Function GetWindowRect Lib "user32" Alias "GetWindowRect" (ByVal hwnd As IntPtr, ByRef lpRect As RECT) As Long
    Private Declare Auto Function GetWindowThreadProcessId Lib "user32.dll" (ByVal hWnd As IntPtr, ByRef ProcessID As Integer) As Integer
    Private Delegate Function EnumWindowsProc(ByVal hwnd As IntPtr, ByVal lParam As Int32) As Int32
    Private Declare Function EnumWindows Lib "user32.dll" (ByVal lpEnumFunc As EnumWindowsProc, ByVal lParam As Int32) As Int32
    Private Declare Function GetClassName Lib "user32" Alias "GetClassNameA" _
         (ByVal hWnd As IntPtr, _
          ByVal lpClassName As String, _
          ByVal nMaxCount As IntPtr) As IntPtr
    '///////////////////////////////////////////////////////////////////////////////////////

    '========  WINDOW RECTANGLE STRUCTURE ===========================
    Public Structure RECT
        Public Left As IntPtr
        Public Top As IntPtr
        Public Right As IntPtr
        Public Bottom As IntPtr
    End Structure

    ''===========   MOUSE CONSTANTS  ========================
    Public MseDown As IntPtr = &H201
    Public MseUp As IntPtr = &H202
    ''=====================================================================

    Dim S1 As IntPtr  'var for holding the S1 window handle ID

    Private Sub Form1_Load(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles MyBase.Load


        S1_Find()  ' locate the S1 app window

        'Set the track dropdow box to index 0, the first item
        TrackCount.SelectedIndex = 0
    End Sub

    Public Sub S1_Find()
        'Points to sub below for enumerating windows
        EnumWindows(AddressOf EnumWindowsCallBack, 0)

    End Sub

    Public Function EnumWindowsCallBack(ByVal hwnd As IntPtr, ByVal lParam As Int32) As Int32

        '  This is the only reliable way I've found to locate the Studio One window
        '  handle without any potential errors related to some of the same class
        '  names being used by other PreSonus apps it's device mixer apps.

        Dim CN As New String(" ", 256)
        Dim ProcessID As Long
        GetWindowThreadProcessId(hwnd, ProcessID)
        Dim tempProc As Process = Process.GetProcessById(ProcessID)
        Dim processName As String = tempProc.ProcessName

        'If the process name is "Studio One" 
        If (processName = "Studio One") Then
            GetClassName(hwnd, CN, 256)
            If InStr(CN, "CCLWindowClass") Then   'if the class name is "CCLWindowClass"
                S1 = hwnd  'Set the handle var
                Return 0  'stop enunerating
            End If

        End If

        Return 1
    End Function

    Public Sub S1_Click(ByVal X As Integer, ByVal Y As Integer)
        Dim ClickPos As IntPtr

        'Set the X/Y coordinates to click on
        ClickPos = SetCoord(X, Y)

        'Send the mouse clicks to Studio One
        SendMessage(S1, MseDown, 1, ClickPos) 'send left mouse button down
        SendMessage(S1, MseUp, 1, ClickPos)   'send left mouse button up

    End Sub

    Public Function SetCoord(ByVal Lo As Short, ByVal Hi As Short) As IntPtr
        'Function for converting coordinates to something usable
        Return CInt(Hi) << 16 Or Lo
    End Function

    Private Sub StoreMixes(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Store1.Click,
    Store2.Click, Store3.Click

        Dim I As Integer  'Indexing for looping

        '/////////////////////////////////////////////////////////////////
        'This routine handles all 3 of the "Store" buttons, using the buttons "tag" property
        'as an identifyer.  There's probably a better way to do this.
        '/////////////////////////////////////////////////////////////////

        'Get the S1 window dimensions in order to locate
        'where the data fields are
        Dim SRect As New RECT()
        GetWindowRect(S1, SRect)

        '==== HOW I LOCATE THOSE TEXT FIELDS ==========
        '  SRect.Bottom is the screen location of the bottom of the S1 window
        '  SRect.Top is the screen locaton of the top of the S1 window
        '  (Bottom - Top) gives window height
        '  The Y coord for those fields is roughly "Window Height - 324 pixels"
        '  Y (the vertical position) = (SRect.Bottom - SRect.Top) - 324

        'Initial text box X/Y coordinates
        '*** 74 & 105 are the data field start positions, but 
        '*** only when all the other mixer panels are closed.
        '*** so this only works when no other panels are open there
        Dim X = 74, Y = (SRect.Bottom - SRect.Top) - 324, X1 = 105, FVal As String = ""

        'Parse the tag property of the sending control
        Select Case sender.tag
            Case Is = "1"
                F1.Items.Clear() : P1.Items.Clear()
                Store1.FlatAppearance.BorderColor = Color.LimeGreen
                Recall1.FlatAppearance.BorderColor = Color.LimeGreen
            Case Is = "2"
                F2.Items.Clear() : P2.Items.Clear()
                Store2.FlatAppearance.BorderColor = Color.LimeGreen
                Recall2.FlatAppearance.BorderColor = Color.LimeGreen
            Case Is = "3"
                F3.Items.Clear() : P3.Items.Clear()
                Store2.FlatAppearance.BorderColor = Color.LimeGreen
                Recall2.FlatAppearance.BorderColor = Color.LimeGreen
        End Select


        'Loop through the text fields and capture fader / pan data
        For I = 0 To (TrackCount.Text) - 1

            S1_Click(X, Y)  'click a text field to activate it

            SetForegroundWindow(S1)  'Make sure S1 is the foreground window for sendkeys

            'This next step can probably be replaced with a SendMessage 
            'to alleviate the need for the window to have focus.
            SendKeys.SendWait("^(c)")  'Send a "copy" command to the text field

            'Get the text from the clipboard
            FVal = Clipboard.GetText.ToString

            'Trim the + sign from the string to avoid an error for recall
            If InStr(FVal, "+") Then FVal = Mid(FVal, 2, Len(FVal) - 1)

            'Evaluate (if) infinity symbol and if so convert to number
            'to avoid an error that results from it.  Make it -144
            If FVal = "-oo" Then FVal = "-144"

            'Parse the sending control tag to see which of
            'three lists to store the data
            If sender.tag = "1" Then F1.Items.Add(FVal)
            If sender.tag = "2" Then F2.Items.Add(FVal)
            If sender.tag = "3" Then F3.Items.Add(FVal)

            'Increment the X screen position for the next
            'click, 71 pixels
            X = X + 71

            'X1 is the X position of the first pan field.
            'Click on it
            S1_Click(X1, Y)

            'Copy the pan data
            SendKeys.SendWait("^(c)")

            'Parse the control tag to see which list the data goes into
            If sender.tag = "1" Then P1.Items.Add(Clipboard.GetText)
            If sender.tag = "2" Then P2.Items.Add(Clipboard.GetText)
            If sender.tag = "3" Then P3.Items.Add(Clipboard.GetText)

            'Increment X1 by 71 pixels for the next click
            X1 = X1 + 71
        Next I

        'Send enter to not leave the last field still activated
        SendKeys.SendWait("{ENTER}")

        'An out of view button to use to shift control focus.
        FButton.Focus()


    End Sub

Last edited by Lawrence; 10-04-2013 at 12:59 PM.
Lawrence is offline   Reply With Quote
Old 10-04-2013, 12:50 PM   #2
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

The rest...

Code:
Private Sub RecallMixes(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Recall1.Click,
        Recall2.Click, Recall3.Click

        Dim I As Integer  'Indexing for looping

        'This routine handles all 3 of the "Recall" buttons, using the buttons "tag" property
        'as a control identifyer. 

        'Get the window dimensions of S1 so we can deduct where
        'the first text field in the docked mixer is
        Dim SRect As New RECT()
        GetWindowRect(S1, SRect)

        'Initial text box X/Y coordinates
        Dim X = 74, Y = (SRect.Bottom - SRect.Top) - 324, X1 = 105

        SetForegroundWindow(S1)  'Give the window focus for sendkeys

        'Restore the pans 
        For I = 0 To (TrackCount.Text) - 1

            'Activate / enter the pan data field
            S1_Click(X1, Y)

            'Parse the sending control
            'Getting the captured values from lists
            Select Case sender.tag
                Case Is = "1"
                    SendKeys.SendWait(P1.Items(I))
                Case Is = "2"
                    SendKeys.SendWait(P2.Items(I))
                Case Is = "3"
                    SendKeys.SendWait(P3.Items(I))
            End Select

            'Increment by 71 pixels
            X1 = X1 + 71
        Next I

        'Resotre the Faders
        For I = 0 To (TrackCount.Text) - 1

            'Activate the current fader data field
            S1_Click(X, Y)

            'Parse the control's tag property and
            'get the correct data from the relevant list
            Select Case sender.tag
                Case Is = "1"
                    SendKeys.SendWait(F1.Items(I))
                Case Is = "2"
                    SendKeys.SendWait(F2.Items(I))
                Case Is = "3"
                    SendKeys.SendWait(F3.Items(I))
            End Select

            'Send enter to make sure no text fields are stil active.
            SendKeys.SendWait("{ENTER}")

            'increment by 71 pixels
            X = X + 71
        Next I

        FButton.Focus() 'change the control focus to a hidden control, cosmetic

    End Sub

    Private Sub Reset_Click(ByVal sender As System.Object, ByVal e As System.EventArgs) Handles Reset.Click

        Dim I As Integer  'Indexing for looping

        '////////////////////////////////////////////////
        'This routine resets all faders and pans to unity / center
        '////////////////////////////////////////////////

        'Get the current window dimensions
        Dim SRect As New RECT()
        GetWindowRect(S1, SRect)

        Dim X = 74, Y = (SRect.Bottom - SRect.Top) - 324, X1 = 105

        SetForegroundWindow(S1)  'Give S1 the focus for sendkeys

        'Loop and set the faders to unity
        'range is from the "trackcount" drop down
        For I = 0 To TrackCount.Text - 1
            S1_Click(X, Y)

            'Send 0 to the fader value field
            SendKeys.SendWait("0")

            'move right 71 pixels for next click
            X = X + 71
        Next I

        'Loop and reset the pans to center
        For I = 0 To TrackCount.Text - 1
            S1_Click(X1, Y)

            'Send C to the pan data field
            SendKeys.SendWait("C")

            'Increment value right by 71 pixels
            X1 = X1 + 71
        Next I

        SendKeys.SendWait("{ENTER}") 'housekeeping, don't leave an edit box activated

        FButton.Focus() 'change the control focus to a hidden control, cosmetic
    End Sub

End Class
Lawrence is offline   Reply With Quote
Old 10-04-2013, 01:10 PM   #3
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

Haven't looked at it closely yet but chances are you'd still need to make at least some of those API calls in .NET since you need to get handles to a separate native process (Reaper).

Though ugly I don't necessarily consider the API calls bad as you'd be doing that in C++ anyway to accomplish the same goal. In other words the ugliness is cross-process communication not really a VB limitation in this case which I don't see how it can be avoided minus having the source code for both.

I'll take a gander later and see if I can think of any ideas though just in case.
__________________
Music is what feelings sound like.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 02:30 PM   #4
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

Thanks, I appreciate it.

What's baffling to me with .net is that you'd think since the same company makes the OS and the language, there would be (for example) some direct way of talking to any external window by simply providing the handle as an ID. Do something like GetWindowRect directly and have it return a simple 4-part array of left, top, etc, etc, instead of going through all of the rigamarole with the API and rectangle structures and all that..
Lawrence is offline   Reply With Quote
Old 10-04-2013, 02:54 PM   #5
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

Quote:
Originally Posted by Lawrence View Post
Thanks, I appreciate it.

What's baffling to me with .net is that you'd think since the same company makes the OS and the language, there would be (for example) some direct way of talking to any external window by simply providing the handle as an ID. Do something like GetWindowRect directly and have it return a simple 4-part array of left, top, etc, etc, instead of going through all of the rigamarole with the API and rectangle structures and all that..
The API is that direct talking. All an API call in this scenario is using one of the OS DLLs to do what the OS can/should do. So User32 is just user32.dll the same one the OS uses to do the same job.

If it were built in to .NET it would be a simpler command which is desirable but as an FYI the runtime is going to call the exact same user32 function behind the scenes because that is how it actually happens. All libraries and runtimes do (aka .NET, JAVA) is reduce complexity and increase safety as far as programming goes. There are some ways in .NET to do many, many things easier but I don't remember how much you can do cross-process. Cross-process is the problem here.

Meaning, the complexity to make cross-process access safe and fun via .NET for example is likely low on the list because NET is hugely big... Did you know you can see every ounce of actual .NET code as written by MS? Just download ILSpy and start opening .NET dlls and you can see every bit of it clear as a bell.
__________________
Music is what feelings sound like.

Last edited by karbomusic; 10-04-2013 at 03:15 PM.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 03:13 PM   #6
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

Nice tip. Will download, thanks.

I hope you don't mind me occassionally leaning on your knowledge. Thanks a lot.
Lawrence is offline   Reply With Quote
Old 10-04-2013, 03:18 PM   #7
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

Quote:
Originally Posted by Lawrence View Post
Nice tip. Will download, thanks.

I hope you don't mind me occassionally leaning on your knowledge. Thanks a lot.
Anytime, you'll love ILSpy for that kind of thing. Its a great way to see how they did what they did and you'll notice if you look in the right places how .NET is a layer on top of all the API type stuff because as you eluded was created to keep much of the dirty work and complexity off of the programmers plate. You can open ANY .net application or DLL written by anyone and look at it that way, the only time you can't see exactly what the programmer wrote is when they obfuscated it which is somewhat rare.

Back in the day the API calls were the only way period, they are still there, they just have layers on top now such as .NET, Classic VB, Python; name your runtime.
__________________
Music is what feelings sound like.

Last edited by karbomusic; 10-04-2013 at 03:43 PM.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 05:29 PM   #8
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

I took a look and I'm impressed you were able to get that much info from another processes GUI like that using VB, nice job. I don't see much other than stuff you would adjust for as you test it and refine things. Everyone loves to hate SendKeys (I forgot that was even still around) but there aren't many other ways to accomplish without actually hooking into the S1 process which is totally asking for buggy issues that would crash S1.

Rambling on.... Hooking in means actually inserting your own code into S1s stack in memory. Lot's of AV vendors do that. What makes it dangerous is S1 knows where it's stack's ceiling is based on what it knows about itself, if someone now inserts a block of code, it makes the ceiling lower than S1 originally calculated and anytime the stack gets close to the ceiling, it'll run past it because it thinks it has more room that what actually exists. Bad situation because the crashes would be 100% unpredictable but that's not what you are doing so you should be fine other than your own refinements.
__________________
Music is what feelings sound like.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 05:54 PM   #9
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

Thanks man. I've then digging into the API's for a long time doing remote things with apps like Cubase so I have a good library of what functions work for most things. I did run into some early issues with .NET compatibility, having to modify some of them to work and also work in x64. (pinvoke, using IntPtr etc.)

Anyway... if you can solve this one below or point me in the right direction you will forever be my hero. What I want to do is drop a file on an app via code... for example... using code like this below to set the data object for a file drop like would happen with a normal file drag and drop with the mouse from a list item ...

Code:
Dim data As DataObject = New DataObject()
        
     data.SetData(DataFormats.FileDrop, New String() {PluginTemplist.Items(I)})
To set a data object like that then "put" that object on an X/Y coordinate on the external window as if I dropped it with the mouse... but with code, not by remote controlling the mouse cursor. I've searched MSDN with no luck. Maybe not even possible, not sure.

In a simpler overview ignoring the code above, maybe just create a new data object from a filename in any way at all (think FX preset) and then "place" (or send) that file / data object somewhere onto an external app in a way that will allow the external window to accept the data (data I know it will accept via drag/drop).

Do you think that's even possible? Thanks.

P.S. You can stop helping me at any time. I'm sure you have better things to do.

Last edited by Lawrence; 10-04-2013 at 06:01 PM.
Lawrence is offline   Reply With Quote
Old 10-04-2013, 06:04 PM   #10
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

Do you mean an actual file or just some text? I'm sure I don't quite get it just yet. What's the goal/big picture? That may help me understand better. IOW what does it do/accomplish for the end-user in their layman terms? You want text "A" from app "A" to show up in a control in app "B" or something?
__________________
Music is what feelings sound like.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 06:10 PM   #11
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

Quote:
Originally Posted by karbomusic View Post
Do you mean an actual file or just some text? I'm sure I don't quite get it just yet. What's the goal/big picture? That may help me understand better. IOW what does it do/accomplish for the end-user in their layman terms? You want text "A" from app "A" to show up in a control in app "B" or something?
To transfer a file from A-B just like what would happen with manual drag and drop. In that case the initial reference to the file (in my example above) is a text string, the file name. The data object gets created from that file.

So basically in VB you're just "dropping the data object" at a certain position and if the app accepts that format it automatically accepts it.

I'm trying to do that in code instead of with the mouse, put a data object on the app window at X/Y... to allow doing it in an array, like a list of FX chain presets being successively / sequentially applied to a series of tracks.

"We of the no API in the daw yet" have to hack it... or try to.

The imaginary code in a perfect world would be something like...

Create Data Object (filename)
Set Data Object(hwnd,coord) with hwnd being the external window

Last edited by Lawrence; 10-04-2013 at 06:16 PM.
Lawrence is offline   Reply With Quote
Old 10-04-2013, 06:20 PM   #12
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

Quote:
Originally Posted by Lawrence View Post
To transfer a file from A-B just like what would happen with manual drag and drop. In that case the initial reference to the file (in my example above) is a text string, the file name. The data object gets created from that file.

So basically in VB you're just "dropping the data object" at a certain position and if the app accepts that format it automatically accepts it.

I'm trying to do that in code instead of with the mouse, put a data object on the app window at X/Y... to allow doing it in an array, like a list of FX chain presets being successively / sequentially applied to a series of tracks.

"We of the no API" have to hack it... or try to.
In all cases I know of that's not really going to be "the file" which is why I ask to make sure I understand. One would normally want to just move the text from A-B then let B use the filename to load the file.

So, I'm assuming due to the hurdles you need to get around the above probably isn't gonna work or you need the actual data to move right?

There is DDE which is old school and allowed programmatic access cross-process and very similar to what you are asking but I'd bet moving the actual data rather than the filename might be troublesome; you may already know about it and it may not work at all:

http://support.microsoft.com/kb/189498
http://support.microsoft.com/kb/142822

I still don't quite understand if it is the name of a preset in a list box that you want the actually underlying data from or if they are literally filenames or just preset names etc. I almost understand just need a few more pieces. Let me know if you need the actual physical data to move during the move instead of a reference to the data such as a filename string.
__________________
Music is what feelings sound like.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 06:37 PM   #13
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

Quote:
Originally Posted by karbomusic View Post
I still don't quite understand if it is the name of a preset in a list box that you want the actually underlying data from or if they are literally filenames or just preset names etc. I almost understand just need a few more pieces. Let me know if you need the actual physical data to move during the move instead of a reference to the data such as a filename string.
In that case it was a literal long file name for a preset or FX chain file...

data.SetData(DataFormats.FileDrop, New String() {PluginTemplist.Items(I)})

"PluginTempList" above was an invisible list, a duplicate of the visible user preset list, but with full long path/file names. The visible preset / chain list used short files names with the extenders stripped.

So the visible list might have "Lo-Pass 50hz" as item 1, while the hidden list item 1 was "C:\...\...\Lo- Pass 50.hz.preset", the full file name to that preset.

They always matched up index wise so dropping item 1 from the visible list would set the data object with the full long file name from the hidden list index 1.

I think you're right about it just being a reference to a file, the data object thing, and not a literal file. Sorry about confusing that... it likely is just a simple string reference / pointer to a file object, not an actual object.

Anyway, the other thing I'm asking may not even be possible.

Last edited by Lawrence; 10-04-2013 at 06:42 PM.
Lawrence is offline   Reply With Quote
Old 10-04-2013, 06:41 PM   #14
karbomusic
Human being with feelings
 
karbomusic's Avatar
 
Join Date: May 2009
Posts: 29,260
Default

For the string/filename DDE might work, might not as it depends on if the target app is DDE aware. It goes back to the 16 bit days so it might.

For the other thing, I haven't quite grasped what the person using the two apps would be doing as in what is the feature to the non-programmer; what's it allow them to do minus what goes on under the covers? If I pick up on what that is there might be more than one way to skin a cat.
__________________
Music is what feelings sound like.
karbomusic is offline   Reply With Quote
Old 10-04-2013, 06:49 PM   #15
Lawrence
Human being with feelings
 
Join Date: Mar 2007
Posts: 21,551
Default

The second app (the vb app) in this case is just a small VB utility toolbar to store and recall snapshots.

In this case I'm storing and recalling fader and pan settings across a range of tracks and was also investigating the possibility of adding FX chains to that, storing and recalling the FX chains per track at the same time by using presets.

As it stands, that particular daw has no native capability for storing and recalling faders and pans or adding FX chains across multiple channels, or snapshots.

Even adding fx chains (if it is even possible) it would still be incomplete because it still wouldn't account for send levels.

Seeing as it will likely be 6 months to a year before another major version (and no guarantee then) I'm trying to temporarily fill some of those gaps myself.

Last edited by Lawrence; 10-04-2013 at 07:08 PM.
Lawrence is offline   Reply With Quote
Reply

Thread Tools
Display Modes

Posting Rules
You may not post new threads
You may not post replies
You may not post attachments
You may not edit your posts

BB code is On
Smilies are On
[IMG] code is On
HTML code is Off

Forum Jump


All times are GMT -7. The time now is 11:32 AM.


Powered by vBulletin® Version 3.8.11
Copyright ©2000 - 2024, vBulletin Solutions Inc.