.NET Framework Interop (CLR, C#, VB)

Post your working scripts, libraries and tools for AHK v1.1 and older
hotkeyguy
Posts: 170
Joined: 11 Oct 2014, 12:22

Re: .NET Framework Interop (CLR, C#, VB)

25 Jul 2016, 16:30

Hello Lexikos,

some additional XPTable questions - I'm using AutoHotkey v1.1.23.06:
  1. ComObject(...), ComObjArray(...),ComObjError(...)
    Can't find anything in the help, only for v2: ComObject()
  2. Tried to set the width of the first column via col1.width := 100. Got an error 0x80131509. Found an hint here: C++ calling C# COM interop error: HRESULT 0x80131509 - Stack Overflow
    Even iWidth := col1.DefaultWidth didn't work.
    (Columns.Add( col4 := CLR_CreateObject( asm, "XPTable.Models.ImageColumn", "ImageColumn", 80 ) ) worked.)
  3. Tried to get the value of XPTable.Models.ColumnAlignment.Center:

    Code: Select all

    oModels := CLR_CreateObject( asm, "XPTable.Models" )
    
    oModels1 := oModels
    oModels2 := oModels.ColumnAlignment
    oModels3 := oModels.ColumnAlignment.Center
    oModels is undefined (string).
  4. CLR and Reflection
    Do you have an simple example?
Many thanks and greetings
hotkeyguy
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

25 Jul 2016, 23:07

I do not use XPTable, do not know its API, and am not interested in debugging it. I merely performed basic translation of C# code (years ago) and tested it. I don't even use CLR.ahk.
hotkeyguy wrote:ComObject(...), ComObjArray(...),ComObjError(...)
Can't find anything in the help,
Try again. ComObjArray and ComObjError are in the index. ComObject is listed as "ComObj...()" because of the "many-named" nature of the function in v1, but "ComObject" can easily be found by searching.
CLR_CreateObject( asm, "XPTable.Models" )
At a guess, I'd say XPTable.Models is a namespace, not a type name.
CLR and Reflection
Do you have an simple example
As I said, "There's an example in CLR_LoadLibrary (calling the static method Assembly.LoadFrom)." I'm not interested in using or teaching .NET. I suggest that you learn (in C# or VB) from any of the countless resources available on the Internet, and then apply that to AutoHotkey. Or just use C#/VB.
User avatar
megnatar
Posts: 92
Joined: 27 Oct 2014, 20:49
Location: The Netherlands
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

26 Jul 2016, 13:08

Ooo sweet feature to the language :D. Thanks for this awesome library and the ongoing development of autohotkey!
payback87
Posts: 13
Joined: 31 Aug 2016, 01:55

Re: .NET Framework Interop (CLR, C#, VB)

15 Sep 2016, 13:27

Hey there,
I was just playing around with your cool library and wondered if it is possible to recreate Automating Lync with Lync SDK via the lib in ahk.

I tried to translate the following powershell script to CLR but it's going totally wrong:

Code: Select all

import-module "path to module\Microsoft.Lync.Model.dll"
$Client = [Microsoft.Lync.Model.LyncClient]::GetClient()
$Client.state
> SigningIn
ahk code:

Code: Select all

dllfile:="path to module\Microsoft.Lync.Model.dll"
CLR_Start()
asm := CLR_LoadLibrary(dllfile)
obj := CLR_CreateObject(asm, "Microsoft.Lync.Model.LyncClient")
cli := obj.GetClient()

msgbox % cli.state
Gives me an error, stating the constructor for Microsoft.Lync.Model.LyncClient could not be found. Is this even possible? Here's a link to microsoft's api reference.
I must admit that I don't know much about programming, I just found CLR and wanted to try out Lync SDK since I'm trying to enhance my lync client a little bit (which I can with vanilla ahk but only via sendinput, window detection, etc.).
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

16 Sep 2016, 02:45

CLR_CreateObject is equivalent to the new operator in C# or possibly the New-Object command in PowerShell. That PowerShell script is just calling a static method.

You can't call static methods directly; you must either use another language or use Reflection. For Reflection, asm (the Assembly object) is the starting point. If you're not familiar with Reflection and don't want to research it by yourself, I would recommend creating a wrapper object in C#/VB instead, as shown in the second post in this thread.
payback87
Posts: 13
Joined: 31 Aug 2016, 01:55

Re: .NET Framework Interop (CLR, C#, VB)

16 Sep 2016, 03:38

Thanks for your reply, will try to look into it. A lot to learn, I have still! :)
User avatar
kczx3
Posts: 1640
Joined: 06 Oct 2015, 21:39

Re: .NET Framework Interop (CLR, C#, VB)

12 Oct 2016, 08:24

How would one be able to reference structures and not classes/objects using this? I am playing around with xptables as well and am able to adjust the text font as I can use the following: headerFont := CLR_CreateObject(drawing, "System.Drawing.Font", "Tahoma", 20).

But I'm struggling to figure out how to adjust the font color as System.Drawing.Color is a struct.

EDIT:
It appears that the following is returning a decimal value... I get myColor to equal 255 with this. But the text color isn't changing. Does it need to be wrapped in a ComObject or SafeArray?

Code: Select all

drawing := CLR_LoadLibrary("System.Drawing")
colorTranslator := CLR_CreateObject(drawing, "System.Drawing.ColorConverter")
myColor := colorTranslator.ConvertFromString("#FF0000")
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

12 Oct 2016, 16:18

See Default Marshaling, and scroll down to "System.Drawing.Color".
User avatar
kczx3
Posts: 1640
Joined: 06 Oct 2015, 21:39

Re: .NET Framework Interop (CLR, C#, VB)

12 Oct 2016, 17:19

lexikos wrote:See Default Marshaling, and scroll down to "System.Drawing.Color".
Reading through this, it seems that I should use an OLE_COLOR type which is a 32-bit integer based on my further searches.

Ole_color = red + 256 * green + 65536 * blue
Where red, green, blue are between 0-255.

This seems odd to me since the code I was using was also returning this number. I will continue to study and try some other things. Thanks.

EDIT:
Oddly enough, I found that my code (full code below with one slight modification) does actually change the row's font color, albeit getting an error in the meantime. Continuing through the error shows that the color is indeed different, though not the color I expect. I simply changed rowStyle.ForeColor := myColor to rowStyle.ForeColor := &myColor

I only thought to do this because of the error: 0x80004003 - Invalid Pointer

Code: Select all

#SingleInstance Force
#Include CLR.ahk

xptables := CLR_LoadLibrary("XPTable.dll")
drawing := CLR_LoadLibrary("System.Drawing")
headerFont := CLR_CreateObject(drawing, "System.Drawing.Font", "Tahoma", 20)
rowFont := CLR_CreateObject(drawing, "System.Drawing.Font", "Tahoma", 12)

colorTranslator := CLR_CreateObject(drawing, "System.Drawing.ColorConverter")
myColor := colorTranslator.ConvertFromString("#FFFFFF")

; Type names must be fully qualified.
table       := CLR_CreateObject(xptables,"XPTable.Models.Table")
columnModel := CLR_CreateObject(xptables,"XPTable.Models.ColumnModel")
columnModel.HeaderHeight := 50
tableModel  := CLR_CreateObject(xptables,"XPTable.Models.TableModel")
tableModel.RowHeight := 50

table.ColumnModel := columnModel
table.TableModel := tableModel
table.HeaderRenderer := CLR_CreateObject(xptables, "XPTable.Renderers.GradientHeaderRenderer")
table.HeaderFont := headerFont

;**************************************************************************************************
; Add our columns to the columnModel
;**************************************************************************************************
Columns := columnModel.Columns
Columns.Add(CLR_CreateObject(xptables, "XPTable.Models.TextColumn", "MyText", 250))
Columns.Add(CLR_CreateObject(xptables, "XPTable.Models.CheckBoxColumn", "MyCheckBox", 250))
Columns.Add(CLR_CreateObject(xptables, "XPTable.Models.ButtonColumn", "MyButton", 250))
Columns.Add(CLR_CreateObject(xptables, "XPTable.Models.ProgressBarColumn", "MyProgress", 250))

;**************************************************************************************************
; Add a row to the tableModel
;**************************************************************************************************
rows := tableModel.Rows
rows.Add(row1 := CLR_CreateObject(xptables, "XPTable.Models.Row"))
row1.Editable := ComObject(0xB, -1)

;**************************************************************************************************
; Add our cells to the previously created row
;**************************************************************************************************
cells := row1.cells
Cells.Add(CLR_CreateObject(xptables, "XPTable.Models.Cell", "MyCell1"))
Cells.Add(CLR_CreateObject(xptables, "XPTable.Models.Cell", "MyCheckbox1", ComObject(0xB, -1)))
Cells.Add(CLR_CreateObject(xptables, "XPTable.Models.Cell", "MyButton1"))
Cells.Add(CLR_CreateObject(xptables, "XPTable.Models.Cell", 75))

;**************************************************************************************************
; Style our row
;**************************************************************************************************
rowStyle := CLR_CreateObject(xptables, "XPtable.Models.RowStyle")
rowStyle.Font := rowFont
rowStyle.ForeColor := &myColor
row1.RowStyle := rowStyle

;**************************************************************************************************
; Release our references
;**************************************************************************************************
columnModel := ""
tableModel := ""

;**************************************************************************************************
; Position the table within its Parent
;**************************************************************************************************
table.Left := 10
table.Top := 10
table.Width := 500
table.Height := 200

hwnd := table.Handle
Gui, +LastFound
DllCall("SetParent", UInt, hwnd, UInt, WinExist())
Gui, Show, W520 H220, XPTable Example

return

GuiClose:
ExitApp
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

12 Oct 2016, 22:12

though not the color I expect.
Of course not. You're supposed to pass a color value, not an address. It's much the same as passing an address (formatted as hex) to PixelSearch or a Gui command (but the color components might be reversed).
User avatar
kczx3
Posts: 1640
Joined: 06 Oct 2015, 21:39

Re: .NET Framework Interop (CLR, C#, VB)

13 Oct 2016, 06:29

Well, there I go trying to make things harder than it is...

Code: Select all

rowStyle.ForeColor := 0x0000FF
That works as expected.

I suppose the next step would be to figure out events.
User avatar
evilC
Posts: 4822
Joined: 27 Feb 2014, 12:30

Re: .NET Framework Interop (CLR, C#, VB)

04 Mar 2017, 23:12

I had a proper play with this tonight, it's very cool.
Rather than do dynamically compiled C#, I am more interested at the moment in calling pre-compiled C# DLLs from AHK.

Attached is a C# solution with a DLL project that implements SharpDX to read joystick information via DirectInput
This will allow us to read the full 8 axes from sticks, rather than the 6 that AHK currently supports.


Demo AHK code that uses CLR to interact with the DLL:
To run, download the ZIP and extract JoystickWrapper\bin\Debug\*.dll to the same folder as the script

Code: Select all

#SingleInstance force
#include CLR.ahk
polled_guids := 0
device_list := {}

asm := CLR_LoadLibrary("JoystickWrapper.dll")
jw := CLR_CreateObject(asm, "JWNameSpace.JoystickWrapper")

_device_list := jw.GetDevices()
Gui, Add, ListView, w300 h100 Checked hwndhDeviceSelect gDeviceSelected Altsubmit, Name|Guid
ct := _device_list.Length()
Loop % ct {
    dev := _device_list.GetIndex(A_Index - 1)
    LV_Add(, dev.Name, dev.Guid)
    device_list[dev.Guid] := dev.Name
}
LV_ModifyCol(1, 200)

Gui, Add, ListView, w300 h300 hwndhDeviceReports, Device|Input|Value
LV_ModifyCol(1, 100)
LV_ModifyCol(2, 150)
Gui, Show

Loop {
    if (IsObject(polled_guids)){
        Gui, ListView, % hDeviceReports
        for polled_guid, unused in polled_guids{
            state := jw.PollStick(polled_guid)
            rpt_ct := state.Length()
            Loop % rpt_ct {
                rpt := state.GetIndex(A_Index-1)
                row := LV_Add(,device_list[polled_guid], rpt.InputName, rpt.Value)
                LV_Modify(row, "vis")
                inp := rpt.InputName
            }
        }
    }
    Sleep 10
}
return

DeviceSelected:
    if (A_GuiEvent != "I")
        return
    Gui, ListView, % hDeviceSelect
    
    if (ErrorLevel = "c"){
        ; Check / Uncheck
        state := ErrorLevel == "C" ? 1 : 0
        LV_GetText(guid, A_EventInfo, 2)
        if (state){
            if (!IsObject(polled_guids))
                polled_guids := {}
            polled_guids[guid] := 1
        } else {
            if (ObjHasKey(polled_guids, guid)){
                polled_guids.Delete(guid)
                if (IsEmptyAssoc(polled_guids))
                    polled_guids := 0
            }
        }
    }
    return

IsEmptyAssoc(arr){
    for k, v in arr
        return 0
    return 1
}

GuiClose:
    ExitApp
Demo video showing input from 2 physical sticks, plus input coming from vJoy (which is being driven by UCR) so I can show it reporting Axis 8, button ID 128 and POV 4.
Image

The C# code (Project included in attached ZIP):

Code: Select all

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using SharpDX.DirectInput;
using System.Diagnostics;

namespace JWNameSpace
{
    public class JoystickWrapper
    {
        private DirectInput directInput;
        private Dictionary<string, Joystick> acquiredSticks = new Dictionary<string, Joystick>(StringComparer.OrdinalIgnoreCase);

        public JoystickWrapper()
        {
            directInput = new DirectInput();
        }

        public int AcquireStick(string guid)
        {
            var joystick = new Joystick(directInput, new Guid(guid));

            // Set BufferSize in order to use buffered data.
            joystick.Properties.BufferSize = 128;

            joystick.Acquire();
            acquiredSticks[guid] = joystick;
            return 1;
        }

        public DeviceList GetDevices()
        {
            var deviceList = new DeviceList();

            foreach (var deviceInstance in directInput.GetDevices())
            {
                if (deviceInstance.Type != DeviceType.Joystick
                    && deviceInstance.Type != DeviceType.Gamepad
                    && deviceInstance.Type != DeviceType.FirstPerson
                    && deviceInstance.Type != DeviceType.Flight
                    && deviceInstance.Type != DeviceType.Driving)
                    continue;
                deviceList.Add(new DeviceInfo(deviceInstance));
                //Console.WriteLine(String.Format("Found stick {0}", deviceInstance.InstanceName));
            }

            return deviceList;
        }

        public DeviceReportList PollStick(string joystickGuid)
        {
            if (!acquiredSticks.ContainsKey(joystickGuid))
            {
                AcquireStick(joystickGuid);
            }
            var joystick = acquiredSticks[joystickGuid];

            joystick.Poll();
            var datas = joystick.GetBufferedData();

            var rep = new DeviceReportList() { Guid = joystickGuid };

            foreach (var state in datas)
            {
                rep.Add(new DeviceReport() { InputName = state.Offset.ToString(), Value = state.Value});
                //Debug.WriteLine(String.Format("AHK| Input {0} changed state to {1}", state.Offset.ToString(), state.Value));
            }
            return rep;
        }

        public void ConsolePollStick(string joystickGuid)
        {
            if (!acquiredSticks.ContainsKey(joystickGuid))
            {
                AcquireStick(joystickGuid);
            }
            var joystick = acquiredSticks[joystickGuid];

            while (true)
            {
                joystick.Poll();
                var datas = joystick.GetBufferedData();
                foreach (var state in datas)
                    Console.WriteLine(state);
            }
        }

    }

    public class DeviceList
    {
        public List<DeviceInfo> List { get; set; } = new List<DeviceInfo>();

        public void Add(DeviceInfo obj)
        {
            List.Add(obj);
        }

        public DeviceInfo GetIndex(int id)
        {
            return List[id];
        }

        public int Length()
        {
            return List.Count;
        }
    }

    public class DeviceInfo
    {
        public DeviceInfo(DeviceInstance deviceInstance)
        {
            Name = deviceInstance.InstanceName;
            Guid = deviceInstance.InstanceGuid.ToString();
        }

        public string Name { get; set; }
        public string Guid { get; set; }
    }

    public class DeviceReportList
    {
        public List<DeviceReport> List { get; set; } = new List<DeviceReport>();
        public string Guid { get; set; }

        public void Add(DeviceReport obj)
        {
            List.Add(obj);
        }

        public DeviceReport GetIndex(int id)
        {
            return List[id];
        }

        public int Length()
        {
            return List.Count;
        }
    }

    public class DeviceReport
    {
        public string InputName { get; set; }
        public int Value { get; set; }
    }

    public class ReturnedObject
    {
        public int BaseInt { get; set; }
    }

}
Some questions:
I seem to be able to return an array[], but do not seem to be able to get by index (using [<index>])) or get the length, so I had to wrap it in a class and provide my own methods to do this.
I take it there is a better way to do this?

At some point I would also like to have the C# code callback to the AHK code (ie an ahk func gets called every time the joystick changes) - I take it I just need to work through the provided Events example to achieve this?
Attachments
JoystickWrapper.zip
(798.37 KiB) Downloaded 397 times
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 01:12

Arrays are marshalled as SafeArrays by default. SafeArrays have only the methods shown in the documentation; i.e. no "length" method or property.

Code: Select all

; Returns an array of strings.
code =
(
    class C {
        public string[] ReturnAnArray() {
            return new [] { "returned", "an", "array" };
        }
    }
)
asm := CLR_CompileC#(code)
o := asm.CreateInstance("C")
arr := o.ReturnAnArray()
MsgBox % arr.MinIndex() ".." arr.MaxIndex() "`n" arr[1]

; Returns an array of objects.
code =
(
    class C {
        public E[] ReturnAnArray() {
            return new [] { new E("alpha"), new E("bravo") };
        }
    }
    class E {
        public string s;
        public E(string a) {
            s = a;
        }
    }
)
asm := CLR_CompileC#(code)
o := asm.CreateInstance("C")
arr := o.ReturnAnArray()
MsgBox % arr[0].s ", " arr[1].s
As for working through the Events example, that would be a start...
User avatar
evilC
Posts: 4822
Joined: 27 Feb 2014, 12:30

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 03:50

I got the Events demo working, but I think I am going to need some kind of multi-threading in my DLL.

However, when I try to extend the sample code to use a thread, I only get 1 callback then it stops:
Does anyone know what I am doing wrong?

Code: Select all

#include CLR.ahk
#Persistent

;~ ; Compile the helper class.
;~ helperAsm := CLR_LoadLibrary("AHK_EventHelper.dll")
;~ helper := helperAsm.CreateInstance("AHK_EventHelper.EventHelper")

; Compile the helper class. This could be pre-compiled.
FileRead c#, EventHelper.cs
helperAsm := CLR_CompileC#(c#)
helper := helperAsm.CreateInstance("EventHelper")

; Create our test object, which simply exposes a single event.
c# =
(
	using System.Threading;
	
    public class ObjectWithEvent {
		Thread oThread;
		
		public void Monitor(){
			oThread = new Thread(new ThreadStart(RaiseEvents));
			oThread.Start();
		}
		
		public void RaiseEvents(){
			while(true){
				RaiseEvent();
			}
		}
		
        public void RaiseEvent() {
            if (OnEvent != null){
				OnEvent(this, System.EventArgs.Empty);
			}
        }
        public event System.EventHandler OnEvent;
    }
)
asm := CLR_CompileC#(c#)
obj := asm.CreateInstance("ObjectWithEvent")

; Add an event handler for the "OnEvent" event.  Use "" to pass the
; address as a string, since IDispatch doesn't support 64-bit integers.
helper.AddHandler(obj, "OnEvent", "" RegisterCallback("EventHandler",,, 1))

; Make an event handler (event must be of the EventHandler type).
handler := helper.MakeHandler("" RegisterCallback("EventHandler",,, 2))
obj.add_OnEvent(handler)

; Test the event handlers.
;obj.RaiseEvent()
;obj.RaiseEvents()
obj.Monitor()


; Our event handler is called with a SAFEARRAY of parameters.  This
; makes it much easier to get the type and value of each parameter.
EventHandler(pprm)
{
    ; Wrap the SAFEARRAY pointer in an object for easy access.
    prm := ComObject(0x200C, pprm)
    ; Show parameters:
    ToolTip, % "Callback #" A_EventInfo
        . "`nSender: " prm[0].ToString()
        . "`nEventArgs: " prm[1].ToString()
}
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 04:25

What you're doing wrong is multi-threading. It is not safe to call script functions on any thread other than the program's main thread.
User avatar
evilC
Posts: 4822
Joined: 27 Feb 2014, 12:30

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 04:33

Is there another way I can have a loop going but not lock up the main thread?
ie have a number of psuedo-threads or something polling sticks, but still allow calls into the main thread to facilitate turning on/off polling?

Can I use SetTimer?
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 05:28

You can do whatever you want in C#.

If you need to call a script function, do it from the right thread. How you do that is up to you. I would suggest using PostMessage and OnMessage, since you're likely already familiar with them.

The Timer class you linked appears to have no connection to the SetTimer command or Win32 function. I'm not familiar with its use.

FYI, I haven't really used .NET at all in the last decade except to develop this library, and I've barely used the library except to test it.
User avatar
evilC
Posts: 4822
Joined: 27 Feb 2014, 12:30

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 05:36

OK, I think I vaguely get what you are saying.

So I can have worker threads running in the DLL, but in order for the callbacks to work, I need to go via the main DLL thread

ie worker thread wants to fire callback, so it communicates to the main DLL thread, and the main DLL thread fires the callback to the AHK script.
lexikos
Posts: 9553
Joined: 30 Sep 2013, 04:07
Contact:

Re: .NET Framework Interop (CLR, C#, VB)

05 Mar 2017, 06:14

Using LresultFromObject and ObjectFromLresult, it's possible to pass an object pointer between processes or threads, automatically giving you a proxy object when necessary. A proxy object does nothing more than forward interface calls back to the original object on its original thread, so is safe to use from outside the main thread. I figured that these functions would be easy enough to use from C#, but it turns out they were completely unnecessary. .NET takes care of it automatically, presumably because it can detect that COM was initialised on the main thread as a "single threaded apartment".

This requires .NET 4.0 (at least, for dynamic):

Code: Select all

#Persistent

c# =
(
    using System;
    using System.Runtime.InteropServices;
    using System.Threading;
    using System.Windows.Forms;
    
    class Util
    {
        public void Test(dynamic handler)
        {
            //; Call script from initial thread.
            handler.Handle("C#");
            
            //; Show initial thread as .NET sees it.
            MessageBox.Show(AppDomain.GetCurrentThreadId().ToString(), "Main thread");
            var t = new Thread(new ThreadStart(() => {
                //; Show new thread as .NET sees it.
                MessageBox.Show(AppDomain.GetCurrentThreadId().ToString(), "New thread");
                //; Call script (this call is forwarded to the initial thread).
                handler.Handle("New thread");
            }));
            t.Start();
        }
    }
)
asm := CLR_CompileC#(c#, "System.Core.dll | Microsoft.CSharp.dll | System.Windows.Forms.dll")
util := asm.CreateInstance("Util")

; Call directly to show thread ID.
Handler.Handle("Direct")

util.Test(Handler)

class Handler
{
    Handle(whence)
    {
        ; This could fail if we were on the wrong thread.
        MsgBox 0, %whence% (script), % DllCall("GetCurrentThreadId")
    }
}
Of course, while handler.Handle() is executing after being called from the new thread, both threads are tied up.

Return to “Scripts and Functions (v1)”

Who is online

Users browsing this forum: gwarble and 127 guests