Jump to content
IGNORED

Benchmarking Languages


Tursi

Recommended Posts

57 minutes ago, Reciprocating Bill said:

Forth and Pascal couldn't be further apart in clarity and readability, however. I wrote a lot of code in Pascal when I got my first Mac in 1984 (TML Pascal, then Think Pascal), since Pascal was essentially the native language of the original MacOS. I still have the source files and can still figure out what I was up too fairly easily. 

 

Forth code I wrote last week can be a mystery.

I know what you mean. 

I find if you try to write Forth the way you use a conventional language it is far to cryptic later. Raw Forth is Assembly language for a 2 stack machine.

I  go out of my way to make Forth into a "language" of sorts that describes the solution in words that relate to the problem so I can understand it easier later.

This normally means factoring the problem into way more small pieces than you would every dream of in conventional language. 

I think using Forth requires changing how you think and if you have to jump back to other languages regularly its hard to keep the Forth head space.

 

I read somewhere when Chuck Moore was first exposed to other people's Forth code he was shocked.  

How they wrote Forth was not what he had in mind. :) 

His definitions are always on one line, 5 to 7 words in each definition.

 

  • Like 4
Link to comment
Share on other sites

I guarantee that I can write a cryptic program in any language.

Pascal was one of the first languages which took a grip on structure. Procedure nesting, logical constructs, data structures, dynamic variables and sets are some examples of features available to create better software structures.

Later it was developed to allow classes too.

The UCSD implementation on the TI is good. Considering its age, very good.

  • Like 5
Link to comment
Share on other sites

16 hours ago, TheBF said:

I  go out of my way to make Forth into a "language" of sorts that describes the solution in words that relate to the problem so I can understand it easier later.

Domain Specific Language is the term, I believe. Of course, Forth is a general language, but with its ability to call a word anything, and the fact that the language itself can be extended, its very open to use for production of DSLs.

  • Like 3
Link to comment
Share on other sites

That is correct. Sometimes a DSL is defined from the beginning to solve a specific kind of task. Sometimes it's so limited that that's about all it can do. Sometimes it's really a fully functional general programming language, but still designed from the beginning for a specific task. Pascal, for example, was from the beginning designed with the task to teaching programming to people in mind. The UCSD Pascal in the TI 99/4A has been expanded to allow it to also become an operating system language.

A lot of the adaptability in Pascal comes from the data structure concept. It's for example quite simple to create a data structure which describes the disk and file structure on the 99/4A, and then write a Pascal program that can handle the standard operating system's files by mapping the disk data into these data structures.

To do the opposite, i.e. write an Extended BASIC program which can read the p-system's files, is significantly more tricky. BASIC doesn't know any more complex data structure than arrays. Just like the Fortran it's a simplified variant of.

 

Disk block based Forth doesn't contain words for handling files, but here too, you can create words that describe the data structure of these files, and then words that operate on this data. It's just not such a fundamental part of the language is it is in Pascal.

 

When I look at benchmarking languages I don't just look at execution time. I consider the development and maintenance time to be important too. I typically develop everything in reasoanbly efficient Pascal (I know quite a lot about what's slow and what's efficient in the implementation for the 99/4A), then evaluate runtime performance. If it's not enough, I consider what's running most of the time, and convert that to assembly manually. Frequently I understand what will be the most time consuming part from the beginning, so I may have a conversion in my mind when I design the Pascal procedure, to make sure a converison is reasoanbly simple. For example, if I need dynamically allocted data in that procedure, I may allocate that one level before, so it's already there when the critical procedure is called. I just pass a pointer to the assembly routine. Allocating a dynamic variable inside the p-system environment directly from assembly is so complex that it's not worth the effort.

  • Like 6
Link to comment
Share on other sites

2 hours ago, apersson850 said:

That is correct. Sometimes a DSL is defined from the beginning to solve a specific kind of task. Sometimes it's so limited that that's about all it can do. Sometimes it's really a fully functional general programming language, but still designed from the beginning for a specific task. Pascal, for example, was from the beginning designed with the task to teaching programming to people in mind. The UCSD Pascal in the TI 99/4A has been expanded to allow it to also become an operating system language.

A lot of the adaptability in Pascal comes from the data structure concept. It's for example quite simple to create a data structure which describes the disk and file structure on the 99/4A, and then write a Pascal program that can handle the standard operating system's files by mapping the disk data into these data structures.

To do the opposite, i.e. write an Extended BASIC program which can read the p-system's files, is significantly more tricky. BASIC doesn't know any more complex data structure than arrays. Just like the Fortran it's a simplified variant of.

 

Disk block based Forth doesn't contain words for handling files, but here too, you can create words that describe the data structure of these files, and then words that operate on this data. It's just not such a fundamental part of the language is it is in Pascal

Forth 83 did not specify file access words.  ISO Forth specifies a file wordset with READ-FILE , OPEN-FILE, WRITE-FILE, CLOSE-FILE etc.  so it is part of the modern language. 

 

2 hours ago, apersson850 said:

When I look at benchmarking languages I don't just look at execution time. I consider the development and maintenance time to be important too. I typically develop everything in reasoanbly efficient Pascal (I know quite a lot about what's slow and what's efficient in the implementation for the 99/4A), then evaluate runtime performance. If it's not enough, I consider what's running most of the time, and convert that to assembly manually. Frequently I understand what will be the most time consuming part from the beginning, so I may have a conversion in my mind when I design the Pascal procedure, to make sure a converison is reasoanbly simple. For example, if I need dynamically allocted data in that procedure, I may allocate that one level before, so it's already there when the critical procedure is called. I just pass a pointer to the assembly routine. Allocating a dynamic variable inside the p-system environment directly from assembly is so complex that it's not worth the effort.

The DSL concept is fundamental to using Forth. When you miss that in trying to use Forth it gets ugly as hell.

I always like to say I stop programming in Forth after about the first 1/2 page of code. (That's hyperbole but it's what I strive for) 

This is a hard thing to get used to, creating the language as you go, when you are used to a set of given keywords.

And it has it's own hazards for maintenance in a large team.  You need to document the DSL so everybody conforms. (Cat herding comes to mind with Forth programmers) :) 

 

Forth is old.  It is generation one of concatenative languages.  There are new ones now that take the advantages of the Forth concept but bring in modern concepts.

  • Factor   (remarkably powerful)
  • OForth  (object Forth)
  • 8th       (full featured for commercial compiler, one source code can create Windows, Linux or Google apps)
  • Kitten  ( experimental graphics language)
     

Data structures are an interesting case.  Wirth did a wonderful job of creating language for defining data structures.

Moore looked at data like an Assembly language programmer. 

It's just memory. Where is your confusion? :) 

 

His position is controversial but he simply gives you :

  • a way to know where free memory begins,  HERE  
  • gives you a way to name memory locations, CREATE
  • allocate any number of bytes, ALLOT 
  • and the most innovative IMHO is the comma.  
    • Comma takes a number as an argument and puts it in the next available free location and bumps the memory tracking variable.
  • a way to assign a code routine to a data structure ,  DOES> 

After that it's all up to you.

: VARIABLE     CREATE    0 ,    ;
: CONSTANT   CREATE    ,    DOES> @ ; 

 

Extendable languages are not be the best for every project but they are sure cool when they fit the job.

Exploring a new I/O board or CPU design come to mind.

Interactive hardware control and interrogation beats the hell out of edit/compile/load/run/fail/repeat

 

They are super cool when you need a new language feature that the compiler writer did not consider.

An interesting case came up here where a forum member asked if you could add string comparison to a CASE structure in PASCAL.

Hmm... ?

 

Forth? When you can extend the compiler yourself... have at it. :) 

\ string case statement

NEEDS DIM  FROM DSK1.STRINGS
NEEDS CASE FROM DSK1.CASE

: $OF    ( -- )
   POSTPONE OVER   POSTPONE =$  POSTPONE IF POSTPONE DROP ; IMMEDIATE

: TEST   ( $ -- )
         CASE
            " APPLE" $OF  ." Granny Smith"  ENDOF
            " PEAR"  $OF  ." Barlett"       ENDOF
            " GRAPE" $OF  ." Concord"       ENDOF
                          ." Unknown fruit"
         ENDCASE  ;
         
  

 

</SERMON>  :) 

 

 

 

 

 

 

  • Like 3
Link to comment
Share on other sites

Note that I'm not any opponent towards Forth. I like the idea, the simplicity in the concept and the fact that you can run things interactively.

The reason for me using Pascal for the TI are several:

  • It was the language to use when I went to school.
  • It was cool to be able to run that at home.
  • The implementation on the TI 99/4A is very good, considering when it became available and the size of the computer.
  • It was, at that time, clearly the best combination of language and operating system on the 99/4A for creating something useful.

I've made a couple of applications, that solved real needs I had, that are thousands of lines long. They still run on the 99/4A, thanks to the p-system's memory management and the fact that a large part of the operating system is on a separate memory card. 36 Kbytes of memory in the Extended BASIC cartride implements many functions. The p-code card has 60 Kbytes...

Sure I could have done that in Forth. But I would have had to define more basic support things before I could start doing the real work. Forth wasn't available when I got my TI either. That's another important point.

One of my larger projects involve a large amount of arithmetic with floating point numbers too. It's not Forth's main strength. Pascal doesn't care. Just declare as integer or real.

  • Like 2
Link to comment
Share on other sites

1 hour ago, apersson850 said:

36 Kbytes of memory in the Extended BASIC cartride implements many functions.

Say more about this. Having never seen a p-system in the silicon, I wasn't aware that Extended BASIC was a component of the system. I assumed that it had its own module, or ran strictly from its expansion box card and disk.   

  • Like 1
Link to comment
Share on other sites

Sorry, I wasn't clear enough. I meant that Extended BASIC had 36 Kbytes, which implements quite a lot of functions in BASIC.

The p-code card, on the other hand, has 60 Kbytes of memory for functions in that system.

They are not related. It was just a comparision of memory sizes.

Link to comment
Share on other sites

1 hour ago, apersson850 said:

Sorry, I wasn't clear enough. I meant that Extended BASIC had 36 Kbytes, which implements quite a lot of functions in BASIC.

The p-code card, on the other hand, has 60 Kbytes of memory for functions in that system.

They are not related. It was just a comparision of memory sizes.

Ok just wondering 36K where are you getting that?

Original XB had 24K of GROM, and 12K of ROM so that is the original versions only.

RXB has 40K of GROM and 20K of ROM, and XB 2.9 has about the same or more.

As far as I know P-code card has not changed in 40 years, but many versions of XB have been released over the years and are presently up to date to this year 2022.

Link to comment
Share on other sites

I understood him to be referring to the original TI Extended BASIC. 

 

I've never been much interested in the UCSD p-system on the TI - until now. The more apersson (but not just any person) writes, the more intriguing it becomes. 

 

Alas, p-code cards are rare and expensive.  

Edited by Reciprocating Bill
"the original"
  • Like 3
Link to comment
Share on other sites

5 minutes ago, Reciprocating Bill said:

I've never been much interested in the UCSD p-system on the TI - until now. The more apersson (but not just any person) writes, the more intriguing it becomes. 

 

Alas, p-code cards are rare and expensive.  

It is a very nice system. 

I think Classic99 would let you play with it with the correct disks on hand, but I have never tried it.

  • Like 1
  • Thanks 1
Link to comment
Share on other sites

8 hours ago, Reciprocating Bill said:

I understood him to be referring to the original TI Extended BASIC. 

Alas, p-code cards are rare and expensive.  

Yes, since when I started using the 99/4A, that was the only Extended BASIC available.

The p-system was expensive back in the 1980's too, since it required virtually everything to run. Hence they are rare today.

Although it doesn't change the p-code card itself, it's already from the beginning designed to be easy to update. A large part of the GROM on the card makes up a virtual disk. The largest file on that disk is SYSTEM.PASCAL. That's the major part of the operating system. The file consists of 23 segments. If you want to, and have the knowledge, you can rewrite any such segment with a new version, compile it and create a library you call SYSTEM.PASCAL. Store that on the system disk, and it will replace the original file. It will now need to be in normal RAM, but only that segment you changed, not the entire file.

 

I've done some minor changes to my operating system that way.

 

Modifying the PME is trickier, but doable. It was easy already back then. Since the p-system fortunately doesn't  need any specific cartridge, you could put in the Mini Memory module in the computer. Since part of the PME is in RAM, you can put in a wedge somewhere and branch to your own code. If you place it in Mini Memory, you don't need to change anything in how the PME uses memory.

As the instruction pointer table for the interpreter is also i RAM, you can modify that too, and thus change how p-code is interpreted, if you want to.

The BIOS is table driven too, so although the original BIOS is in ROM on the p-code card, you can rewrite sections of it to reside in cartridge space, then just change the pointer. The system's functions for devices, like initialize, clear, read and write to the units are in the BIOS, but they are pointed to by a memory structure in RAM.

 

So unlike BASIC, the complete p-system was possible to modify already with the hardware available from TI back then. With today's memory devices, it's even easier.

  • Like 2
Link to comment
Share on other sites

19 hours ago, TheBF said:

An interesting case came up here where a forum member asked if you could add string comparison to a CASE structure in PASCAL.

Hmm... ?

I didn't remember that question, but it's a typical case (pun intended) of when you get a "here is what we have - live with it" situation in Pascal.

To make it efficient, there's even a special p-code instruction that executes the case statement. That p-code requires an enumerated data type to work with, like an integer, character or a user defined enumerated type. But not a complex datatype like real or string.

On the other hand, case is (by p-system standards) lightning fast.

  • Like 4
Link to comment
Share on other sites

  • 1 year later...
On 7/6/2017 at 2:18 PM, Tursi said:

 

We don't have TI LOGO in there, anyone want to try that one? ;)

It has been over 6 years since you asked....and my TI LOGO II code has finally finished running (ok, ok, it only felt like 6 years):

 

//DEFINE PROCEDURES FOR EACH SIDE OF SCREEN

TO TOP :NUM
IF :NUM = 116 THEN STOP
SX :NUM
TOP :NUM + 1
END

 

TO RGT :NUM
IF :NUM = -81 THEN STOP
SY :NUM
RGT :NUM - 1
END

 

TO BTM :NUM
IF :NUM = -128 THEN STOP
SX :NUM
BTM :NUM - 1
END

 

TO LFT :NUM
IF :NUM = 96 THEN STOP
SY :NUM
LFT :NUM + 1
END

 

//USE GRAPHICS EDITOR TO CREATE 16X16 ASTERISK

MAKESHAPE 6

 

//INITIALIZE SPRITE IN UPPER LEFT CORNER OF SCREEN

TELL SPRITE
CARRY 6
SC :BLACK
SXY -128 96

 

//CLEAR SCREEN AND LOOP 100 TIMES

CS
REPEAT 100 [TOP -128 RGT 96 BTM 116 LFT -81]

 

 

 

Updated standings:

On 7/26/2017 at 12:11 PM, TheBF said:
Language   First Pass     Optimized
GCC           15 sec         5 sec
Assembly      17 sec         5 sec
TurboForth    48 sec        29 sec
CAMEL99 DTC   49 sec        27 sec
Compiled XB   51 sec        37 sec
CAMEL99 ITC   55 sec        29 sec
FbForth       70 sec        26 sec
GPL           80 sec       none yet
ABASIC       490 sec       none yet
LOGO II	    1920 sec       none yet
XB          2000 sec       none yet
UCSD Pascal 7300 sec       273 sec

 

 

Logo II.jpeg

Edited by Switch1995
  • Like 2
Link to comment
Share on other sites

3 hours ago, Switch1995 said:

It has been over 6 years since you asked....and my TI LOGO II code has finally finished running:

 

//DEFINE PROCEDURES FOR EACH SIDE OF SCREEN

TO TOP :NUM
IF :NUM = 116 THEN STOP
SX :NUM
TOP :NUM + 1
END

 

TO RGT :NUM
IF :NUM = -81 THEN STOP
SY :NUM
RGT :NUM - 1
END

 

TO BTM :NUM
IF :NUM = -128 THEN STOP
SX :NUM
BTM :NUM - 1
END

 

TO LFT :NUM
IF :NUM = 96 THEN STOP
SY :NUM
LFT :NUM + 1
END

 

//USE GRAPHICS EDITOR TO CREATE 16X16 ASTERISK

MAKESHAPE 6

 

//INITIALIZE SPRITE IN UPPER LEFT CORNER OF SCREEN

TELL SPRITE
CARRY 6
SC :BLACK
SXY -128 96

 

//CLEAR SCREEN AND LOOP 100 TIMES

CS
REPEAT 100 [TOP -128 RGT 96 BTM 116 LFT -81]

 

 

 

Updated standings:

 

Logo II.jpeg

Hey thanks for this. Great to see a result. 

 

 

  • Like 2
Link to comment
Share on other sites

Someone else will have to verify my sprite's x & y locations are taken through all the required positions (even if the VDP doesn't see most of them, like the other assembler & C versions)...

 

Spoiler

        AORG >A000
* assumes startup from Editor/Assembler

  DEF START
  REF VDPWA,VDPWD
  
* make it work as EA5 if desired
  B @START

START
  lwpi >8300
  li r1,>8320
  li r0,l140
! mov *R0+,*R1+
  ci R1,>8400
  jne -!

* call clear
  li r0,>0040     * write address >0000
  movb r0,@VDPWA
  swpb r0
  movb r0,@VDPWA
  
  li r1,>2000
  li r2,768
lp1
  movb r1,@VDPWD
  dec r2
  jne lp1
  
* call magnify(2)
  li r0,>c181     * write VDP register 1 with >C2 (16k,enable, no int, double-size sprites)
  movb r0,@VDPWA
  swpb r0
  movb r0,@VDPWA
  
* call sprite(#1,42,2,1,1)
  li r0,>0186     * vdp register 6 to >01 (sprite descriptor table to >0800)
  movb r0,@VDPWA
  swpb r0
  movb r0,@VDPWA

  li r0,>0043     * write address >0300
  movb r0,@VDPWA
  swpb r0
  movb r0,@VDPWA
 
  li r0,>002A     * 1,1 (minus 1) and 42
  movb r0,@VDPWD
  nop
  movb r0,@VDPWD
  swpb r0
  movb r0,@VDPWD
  
  li r0,>01d0     * color 2 (-1) and list terminator
  movb r0,@VDPWD
  swpb r0
  movb r0,@VDPWD
  
* cnt=100
  li r5,10000
  
  li r0,>0143     * write address >0301 (X pos)
  li r6,>4310
  li r1,>0043     * write address >0300 (Y pos)
  li r7,>4300
  clr r4
  B @>8320
l140
  li r3,>0100

* for x=1 to 240 (minus 1 for asm)
xlp1

* call locate(#1,1,x)
  movb r0,@VDPWA
  nop
  movb r6,@VDPWA
  nop
  movb r3,@VDPWD  
  
* next x
  ai r3,>0100
  ci r3,>ef00
  jne xlp1

  movb r0,@VDPWA
  nop
  movb r6,@VDPWA
  nop
  movb r3,@VDPWD  
  jmp opt1
  
* for y=1 to 176
ylp1

* call locate(#1,y,240)
  movb r1,@VDPWA
  nop
  movb r7,@VDPWA
  nop
  movb r4,@VDPWD
  
* next y
opt1
  ai r4,>0100
  ci r4,>af00
  jne ylp1

  movb r1,@VDPWA
  nop
  movb r7,@VDPWA
  nop
  movb r4,@VDPWD
  nop
  
* for x=240 to 1 step -1
xlp2

* call locate(#1,176,x)
  movb r0,@VDPWA
  nop
  movb r6,@VDPWA
  nop
  movb r3,@VDPWD  
  
* next x
  ai r3,>ff00
  jne xlp2

  movb r0,@VDPWA
  nop
  movb r6,@VDPWA
  nop
  movb r3,@VDPWD  
  jmp opt2
  
* for y=176 to 1 step -1
  
ylp2
* call locate(#1,y,1)
  movb r1,@VDPWA
  nop
  movb r7,@VDPWA
  nop
  movb r4,@VDPWD
  
* next y
opt2
  ai r4,>ff00
  jne ylp2

  movb r1,@VDPWA
  nop
  movb r7,@VDPWA
  nop
  movb r4,@VDPWD
  
* cnt=cnt-1
  dec r5
  jne l140
  
* end
  clr  @>83C4
  blwp @>0000
  
  end
 

 

This does 10,000 iterations and finishes in 6 minutes 30 seconds (390 seconds) which is 3.9 seconds for 100.

 

Rounding up (as mentioned here) makes this 4 seconds.

 

The most interesting thing though, to me, is the screen saver kicks in about 35 seconds after the blwp 0.  When I was a teenager, the screen saver never kicked in, I was always too busy pressing keys.  (Yeah, and I didn't really need r7 when r6 has the same byte value.)

Link to comment
Share on other sites

7 hours ago, TheBF said:

Off the top is you add the line: 

SETO @>83D6

I think the screen saver will never kick in.

 

That is sort of correct. The STT (Screen Timeout Timer) at >83D6 is incremented by 2 each time through the console ISR (Interrupt Service Routine) and then tested for 0. If the STT has rolled over to 0, the screen is blanked.  The STT is reset (=0) each time a key/joystick event is detected by KSCAN (console keyboard scan routine). There are 32768 ticks from reset to rollover (9.1 minutes). If you set the STT to an odd number, as with your SETO suggestion (-1), it will never roll over to 0. The very next key/joystick event’s resetting of the STT will, obviously, defeat your odd setting.

 

...lee

  • Like 3
Link to comment
Share on other sites

I took a run at this a while back using a function that did the sprite motion with data arguments and then did all the rest in Forth.

I can't get to 3.9 seconds but I am at about 5.5 which is not bad considering all the args and the outer loop are running through the interpreter.

 

Edit:

Update:  Just ran on real iron and 1000 iteration took 47.5 seconds, so a bit faster than my old Dell and Classic99.

 

\ TURSI'S TI-99 Sprite Benchmark with Assembler function 

NEEDS MOV,     FROM DSK1.ASM9900 
NEEDS LOCATE   FROM DSK1.DIRSPRIT

HEX
8C00 CONSTANT VDPWD
8C02 CONSTANT VDPWA
300 CONSTANT >300 
301 CONSTANT >301 

CODE MoveSprite ( Vaddr2 Vaddr1 SprAddr direction -- )
    ( R4 = top-of-stack (TOS) = direction)
    R1 POP,                  \ Sprite table address
    R1 4000  ORI,            \ set control bits to write mode (01)
    R2 POP,                  \ 1st screen location
    R3 POP,                  \ last screen location
\ port addresses in registers     
    R0  VDPWD LI, 
    R13 VDPWA LI,  
    R8 STWP,                  \ workspace in kept in R8
    BEGIN,
        3 R8 () R13 ** MOVB,   \ write odd byte of  R1
        R1 R13 ** MOV,         \ MOV writes the even byte
    
        5 R8 () R0 ** MOVB,    \ write char to vdp data port
        TOS R2 ADD,
        R2  R3 CMP,
    EQ UNTIL,
    TOS POP,                   \ refill TOS register R4
    NEXT,
    ENDCODE

CODE 0LIMI    0 LIMI,  NEXT, ENDCODE 

DECIMAL
: TURSI.BOOSTER
    0LIMI 
    GRAPHICS
    [CHAR] *  9  0 0  0 SPRITE    1 MAGNIFY    

    100 0 
    DO
    \ end  start spr  dir
      239    0   >301  1 MoveSprite
      175    0   >300  1 MoveSprite
        0  239   >301 -1 MoveSprite
        0  175   >300 -1 MoveSprite
    LOOP
;
( ITC ~5 seconds)

 

 

 

  • Like 2
Link to comment
Share on other sites

8 hours ago, TheBF said:

I took a run at this a while back using a function that did the sprite motion with data arguments and then did all the rest in Forth.

I can't get to 3.9 seconds but I am at about 5.5 which is not bad considering all the args and the outer loop are running through the interpreter.

 

Edit:

Update:  Just ran on real iron and 1000 iteration took 47.5 seconds, so a bit faster than my old Dell and Classic99.

That's quite impressive.

 

Mine was on real iron too.  I don't think I can improve on it, other than fixing the typo in the lower byte of R6 (which isn't used anyway, but makes it confusing) and changing one NOP to a JMP opt3 in the middle which I missed (the sprite position in the original gets set twice in each corner, I meant to fix all corners but missed that one).  It's probably only worth a few seconds over 10,000 iterations, so wouldn't really change anything.  I'd be interested in seeing a faster solution.

  • Like 2
  • Thanks 1
Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...