Jump to content
IGNORED

Camel99 Forth Information goes here


TheBF

Recommended Posts

Heresy of Heresies :) 

 

Over on Reddit Forth there was a discussion on local variables.

Stephen Pelc of VFX Forth said that their experiments show as much as 50% slowdown using locals instead of the data stack.

One of the posts was made by a Forth implementer (Zeptoforth) who said that he has switched to using locals a lot and it has NO effect on the speed of his programs. 

His thinking is that locals would only be much slower on a compiler that converts stack data into register assignments but on regular Forth compilers it is neutral.

 

That made me wonder...

I had one version of "cheap" locals that I knew was not optimal and one that used 9900 index addressing into the return stack. 

The downsize of the indexing version was you needed to create two names: 1 to fetch and 1 to store.

 

In my past tests the Forth version of BENCHIE using a fast VALUE ran in 24.3 seconds. 

The non-optimal locals version ran in 48 seconds... but I had never done the test with the better version. 

 

Turns out that guy on Reddit was correct. In fact the locals version ran a bit faster on Camel99 Forth. :( 

I think this is because of the 9900 property that less instructions is almost always faster.

 

In this case local fetch is:

MOV n(RP),TOS 

 

Store to local is 

MOV TOS,n(RP)

That's about as good as it can get.

 

So it makes me think I could make some kind of defining word that creates a double CFA word.

By default the local does a fetch from return stack to the data stack.

Then make a word like TO for values that compiles the store code address when we assign to a local.

 

Here is the experiment code. 

 

Spoiler
\ Benchie.fth from the internet

\ tForth (20 MHz T8): 196 bytes 0.198 sec
\ iForth (33 MHz '386): 175 bytes 0.115 sec
\ iForth (40 MHz '486DLC): 172 bytes 0.0588 sec
\ iForth (66 MHz '486): 172 bytes 0.0323 sec
\ RTX2000: 89 bytes 0.098 sec (no Headers)
\ HSF2000 (1.6GHz AMD Sempron) ?? bytes  0.22 secs
\ 8051 ANS Forth (12 MHz 80C535): 126 bytes 15,8 sec (met uservariabelen)
\ HSF2000 2014 with a 2.1 Ghz Intel  0.05 seconds.
\         increased loop size X10   0.16

\ CAMEL99 v2.7    
\ W/FAST VALUES      24.21
\ W/locals           24.08 
\ TurboForth V1.2.1  24.6  (for reference)

NEEDS ELAPSE FROM DSK1.ELAPSE
NEEDS DUMP   FROM DSK1.TOOLS
NEEDS VALUE  FROM DSK1.VALUES 


HERE
HEX
CODE LOCALS ( n --) \ build a stack frame n cells deep
\ *pushes the original RP onto top of rstack for fast collapse
\ RP R0 MOV, TOS 1 SLA, TOS RP SUB,   R0 RPUSH,     TOS POP,
  C007 ,    0A14 ,   61C4 ,    0647 , C5C0 ,  C136 ,  NEXT,  
ENDCODE

CODE /LOCALS  ( -- ) \ collapse stack frame
    C1D7 , NEXT, \ *RP RP MOV, NEXT,
ENDCODE

\ Local variable compilers make named code words
: GETTER  ( n --) \ create name that returns a contents of a local
\           TOS PUSH,  ( n) 2* (RP) TOS MOV,  NEXT,  ;
  CODE     0646 , C584 , C127 , CELLS ,       NEXT,  ;

: SETTER ( n --) \ create name that sets contents of a local
\      TOS SWAP CELLS (RP) MOV, TOS POP, 
  CODE    C9C4 ,   CELLS ,    C136 ,  NEXT,  ;

: ADDER  ( n -- ) \ defines a local for +! operation
\      TOS SWAP CELLS (RP) ADD, TOS POP, 
  CODE    A9C4 ,   CELLS ,    C136 ,   NEXT,  ;

\ defines a "setter" and a "getter"   
: LOCAL:  ( n ) DUP GETTER  SETTER  ;


\ conventional BENCHIE 
HEX
100 CONSTANT MASK
  5 CONSTANT FIVE
    VALUE BVAR
: BENCHIE
        MASK 0
        DO       \ locals work inside do loop   
            1
            BEGIN
              DUP SWAP DUP ROT DROP 1 AND
              IF FIVE +
              ELSE 1-
              THEN TO BVAR
              BVAR DUP MASK AND
            UNTIL
            DROP
        LOOP
;  \ 24.21 seconds 

\ BENCHIE with locals 
\ create two names. one to fetch, one to store 
\        fetch   store  
1 LOCAL: BVAR    BVAR! 
2 LOCAL: NDX     NDX! 

: BENCHIE2 
        1 LOCALS \ define outside do loop  
        MASK 0
        DO       \ locals work inside do loop   
            1
            BEGIN
              DUP SWAP DUP ROT DROP 1 AND
              IF FIVE +
              ELSE 1-
              THEN BVAR!
              BVAR DUP MASK AND
            UNTIL
            DROP
        LOOP
        /LOCALS
;  \ 24.08 seconds 

 

 

 

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

I made the mistake of trying to use the Camel99 Linker. Homemade software. Amateur coder.

What can I say. :( 

 

Anyway I had to dig in and make external references work properly. Turns out I had not tested that functionality enough. 

Currently I only can handle relocatable code. AORG programs may not be possible with this method. (?)

 

Explanation:

All the DEF references in a "library" become words in the DEFS vocabulary when the object file is linked. 

The DEFS words become Forth CONSTANTs.

They just return the entry address of the external code, where it sits in TI-99 low RAM after linking. 

 

When your program has REF to an external DEF the object code, it has strings embedded in it at the end that are

"tagged" with a '3' if it is a RELOCATABLE reference. 

 

Like this: 

30016VFILL 30026VMBW

 

After the '3' the next 4 chars are a number which is the offset in memory (from the current load point) where there is a missing address. 

The next 6 characters are the text name of the missing program.

Using Forth's FIND function, which we restrict to looking only in the DEFS vocabulary, we get the missing address and store it (with !) 

in low RAM at the offset+BASE-MEM.  Simple! :)

 

Here is the business end of the code for TAG 3 written "linker Forth" ;) 

This made it pretty simple to create the functionality once I got my head around it.

: RELOCATE ( addr -- addr')  BASE-MEM + ;

: ?REF
    0= IF
       CR
       CR ." ? External REF not loaded"
       CR ." >>> " PAD COUNT TYPE
       ABORT
    THEN ;

: FIND-EXT  
    ONLY DEFS FIND         \ search only the defs vocabulary
    ONLY FORTH ALSO DEFS ; \ restore normal searching 

: TAG3
   PARSE# RELOCATE >R          \ External address to Rstack 
   GETLABEL PAD PLACE          \ store label in PAD    
   PAD FIND-EXT NIP ?REF       \ check the dictionary  
   PAD COUNT EVALUATE          \ evaluate DEF word to generate the address 
   ( defaddr ) R> !            \ store the def addr in Extern memory
;

 

 

So here is LINKERIII. 

It still does not let you save a binary version of your program but you can explore and run sub-routines and programs

and change data in your assembly language programs all from within Camel99 Forth. 

 

CAMEL99-ITC/UTILS/LINKER at master · bfox9900/CAMEL99-ITC (github.com)

 

And here is a video of how you might use it. 

 

  • Like 3
Link to comment
Share on other sites

I proposed the idea of using Intel HEX format for transforming binary files to a Pascal program over in another thread.

 

I reached back into my archive and found the program I used to get the a binary image of my MaxForth programs sent to the terminal.

I would send this code to Maxforth over RS232.

Then I would turn on the file capture on the terminal and type something like 

 

HEX 0 2000 INTELHEX 

to capture an 8K of RAM at address >0000.

Then I could send the intelHEX file to my ROM burner. Ah.. the good old days. 

 

The output from the program is in the video. I am working on a parser and loader so that I can transfer data to my hardware TTY Forth.

 

Here is the code for Camel99 Forth. 

Spoiler
( INTELHEX  dump data in INTEL-HEX FORMAT )

( Written by the late Randy Dumsy for Maxforth )
( *** This program was used with the MOTOROLA 68HC11 *******)
( Ported to Camel99 Forth Jan 17 2024)

( INSTRUCTIONS:                        )
( Load this program by sending it to the new-micros computer board )
( PLace your terminal program in the capture mode)
( Then type in the start address, the number of bytes and the command )
( INTELHEX  ( Example:  HEX 000 800 INTELHEX )
( MAX-FORTH will then transmit the INTEL format to you!)


\ ** for camel99 forth **

INCLUDE DSK1.OUTFILE  \ echoes emit, cr, type, to a file
INCLUDE DSK1.TOOLS 
HEX

VARIABLE CHKSUM
( convert and emit)
: .DIGIT  ( char -- ) DUP 0A < IF [CHAR] 0  ELSE [CHAR] 7 THEN + EMIT ; 
: 2.R     ( c --) FF AND 10 /MOD .DIGIT .DIGIT ;
: 4.R     ( n --) 0  100 UM/MOD  2.R  2.R ;

\ convert address to 16 byte boundary (for DOS memory) :-)))
: 16'S    ( addr -- addr')  10 /  1+ 10 * ; 

\ size constants 
 400 CONSTANT 1K
 800 CONSTANT 2K
1000 CONSTANT 4K
2000 CONSTANT 8K

\ we must use S" and TYPE for the redirection to file to work. 
: INTELHEX ( addr count -- )
    OVER + SWAP
    BEGIN
        CR
        2DUP 10 + MIN   \ make line of output upto 16 bytes long
        SWAP
        S" :" TYPE               \ 
        2DUP -              \ calc number of bytes in file)
        DUP CHKSUM !        \ initialize the chksum )
        2.R
        DUP >< FF AND  OVER  FF AND +  CHKSUM +! ( PFM!)
        DUP 4.R             \ print it   
        S" 00" TYPE               \ need some zeros 
        >R DUP R>           \ 
        DO
            I C@ DUP 2.R    \ print out a byte ... 
            CHKSUM +!       \ ... also put it in the CHKSUM
        LOOP
        CHKSUM 1+ C@ NEGATE 2.R
        2DUP =
    UNTIL
    CR S" :00000001FF" TYPE  CR      ( print end of file record)
    2DROP ;

 

 

 

  • Like 3
Link to comment
Share on other sites

Ok this took me longer to get working than it should have but here is an Intel HEX loader. 

 

I got a bit cheeky. :) 

Since the data consists of lines of text we need some kind of parser.

I wondered if  could use the approach we see in string words that take address,length pairs and return a new string pair. 

 

So the secret of this method is CHOP.  CHOP takes a string and CHOPs at an index but it returns both the chopped string AND the remaining string underneath.

\ renamed for clarity of purpose 
: LEFT$  ( addr len n -- addr len' ) NIP ;
: RIGHT$ ( addr len m -- addr' len') /STRING ;

: CHOP  ( addr len n -- addr1 len1 addr2 len2 )
    >R                 \ we need n twice, use return stack 
    2DUP  R@ LEFT$     \ substring 
    2SWAP R> RIGHT$    \ "RIGHT$" is remainder
    2SWAP ;            \ substring back on top
;

 

Armed with this powerful tool we can make field "tags" that chop the correct number of bytes from the string and convert them to a number. 

: $VAL  ( addr len -- n) NUMBER? ABORT" Bad number" ;

: <?START>  ( addr len -- addr' len') 
   1 CHOP DROP  C@ [CHAR] : <> ABORT" Start code not found" ; 

: <RECLEN>  ( addr len -- addr' len' n ) 2 CHOP $VAL ;
: <ADDRESS> ( addr len -- addr' len' n ) 4 CHOP $VAL ; 
: <RECTYPE> ( addr len -- addr' len' n ) 2 CHOP $VAL ;
: <DATA>    ( addr len -- addr' len' n ) 4 CHOP $VAL ;

\ This is the last tag. It consumes the string. 
: </CHKSUM>  ( addr len --  n ) $VAL CHKSUM ! ; 

This becomes our little language for parsing the lines of text. 

I think this is a very different way to make a parser that we might see in another language. 

 

This all culminates with LOADHEX that reads an IntelHEX file and loads it into memory per the addresses in the file. 

It's a bit like TI object code but with only one function. ie: put these numbers in memory. 

 

The rest was just getting the whole thing organized into a loop structure.

Again I resorted to the double WHILE construct because we need to test two conditions.

Is the file empty and is this the "end of data: record. 

 

The entire file is here. I have not implemented computing the checksum on the file data as it is read.

I should be able to use Randy's code from the IntelHEX encoder to make that work and add the calculation to the <DATALINE> tag.

That's a follow up job.

 

Spoiler
\ HEXLOADER.FTH  loads intel hex files into memory   Fox 2024 

INCLUDE DSK1.TOOLS 
INCLUDE DSK1.ANSFILES 
INCLUDE DSK1.VALUES 

VARIABLE CHKSUM

HEX 
\ renamed for clarity of purpose 
: LEFT$  ( addr len n -- addr len' ) NIP ;
: RIGHT$ ( addr len m -- addr' len') /STRING ;

: CHOP  ( addr len n -- addr1 len1 addr2 len2 )
    >R                 \ we need n twice, use return stack 
    2DUP  R@ LEFT$     \ substring 
    2SWAP R> RIGHT$    \ "RIGHT$" is remainder
    2SWAP ;            \ substring back on top
;

: $VAL  ( addr len -- n) NUMBER? ABORT" Bad number" ;

\           *** data management tags ***
\ Words that are in angle brackets operate on a string.
\ They take a part of the string convert it to a number
\ and leave the remainder of the string on the data stack 
\ for the next function to use. 

\ aborts if start code not found 
: <?START>  ( addr len -- addr' len') 
   1 CHOP DROP  C@ [CHAR] : <> ABORT" Start code not found" ; 

: <RECLEN>  ( addr len -- addr' len' n ) 2 CHOP $VAL ;
: <ADDRESS> ( addr len -- addr' len' n ) 4 CHOP $VAL ; 
: <RECTYPE> ( addr len -- addr' len' n ) 2 CHOP $VAL ;
: <DATA>    ( addr len -- addr' len' n ) 4 CHOP $VAL ;

\ This is the last tag. It consumes the string. 
: </CHKSUM>  ( addr len --  n ) $VAL CHKSUM ! ; 

DECIMAL 
0 VALUE #1  \ file handle holder 

: OPEN     ( fam -- )  OPEN-FILE ?FILERR ;
: READLN   ( addr -- addr size ?) DUP 80 #1 READ-LINE NIP 0= ;

: <DATALINE> ( addr len -- ) BOUNDS DO  <DATA> I !  2 +LOOP ;

: LOADHEX ( path$ len -- )
    HEX 
    DV80 R/O OPEN TO #1 
    BEGIN 
       PAD READLN  ( -- addr len ?)
    WHILE ( read=true)
       <?START>      \ TEST for start code. Abort if wrong
       <RECLEN>  >R  \ use this later 
       <ADDRESS> >R  \ use this later ( r: -- len addr)
       <RECTYPE> 0=  \ 0 means a data record  
    WHILE     
       R> R>       ( -- addr len )    
       <DATALINE>  \ parse the data and write to memory 
       </CHKSUM>   \ clean up what's left, record chksum
    REPEAT 
     UNLOOP        \ first time I ever used this word :-)
    THEN 
    2DROP
    #1 CLOSE-FILE ?FILERR
;

 

 

 

Edited by TheBF
removed debug code lines
  • Like 3
Link to comment
Share on other sites

So LOADHEX is not a speed demon. 27 seconds to parse and load a 4K test file to memory.

I think a big part of speed is in the word  >NUMBER which is written for double number conversion. 

If we wrote a bespoke 16 bit number convertor we might get it a bit faster. But I am ok with it. 

 

The alternative is to do what @VORTICON did and write the output to a binary file that the file system can inhale. 

Hmmm.  INHALE might be good name for that file function. :)

 

4k INTEL HEX LOAD.png

  • Like 1
Link to comment
Share on other sites

4 hours ago, TheBF said:

So LOADHEX is not a speed demon. 27 seconds to parse and load a 4K test file to memory.

I think a big part of speed is in the word  >NUMBER which is written for double number conversion. 

If we wrote a bespoke 16 bit number convertor we might get it a bit faster. But I am ok with it. 

 

The alternative is to do what @VORTICON did and write the output to a binary file that the file system can inhale. 

Hmmm.  INHALE might be good name for that file function. :)

 

4k INTEL HEX LOAD.png

Can you post that text HEX file? I'd like to use it as a test as well.

Link to comment
Share on other sites

I supposed it's worth showing how I made the file.

 

I used a little fill memory loop and then used my OUTFILE library to capture the output of INTELHEX to a file. 

 

Here is the "script". 

INCLUDE DSK1.OUTFILE
INCLUDE DSK2.INTELHEX

\ FILLMEMORY 
HEX 
: FILLMEM ( ADDR #cells -- ) 
  0 DO
       I OVER ! 
       CELL+
  LOOP 
;

HEX 2000 1000 FILLMEM

S" DSK2.4KHEXDUMP" MAKE-OUTPUT 
2000 1000 INTELHEX 
CLOSE-OUTPUT

 

Is not perfect because it has one empty line at the top so I just loaded it into the editor and removed the top line and re-saved. 

 

 

  • Like 1
Link to comment
Share on other sites

11 hours ago, TheBF said:

I think the formatting is wrong here. The data length entry is half of what it should be. Per my understanding of the Intel Hex specification, each hex character is counted, not each hex digit pair. Also it looks like you are using a field of 4 for each data item where it should be only 2.

I assumed that each data record will be on a separate line but can't find documentation on this. If not I will need to make modifications to my conversion program...

 

Link to comment
Share on other sites

Interesting.

The encoder that I am using is a hand me down from the MaxForth development system but Randy, the author, died in 2022 so we can't ask him. 

It is possible that there is a dependancy in his code for Motorola 6811 CPU. A byte order thing would not surprise me given the 9900's personality.

 

I see he does the checksum computation at the byte level by taking each byte in a integer adding them together and adding that sum to the CHKSUM.

DUP >< FF AND  OVER  FF AND +  CHKSUM +! 

 

There are a number of data length options for IntelHEX. In the Wikipedia article it shows what looks like extensions for larger word size for new CPUs.

 

What I know is that this program output a file that worked perfectly with an old EPROM burner that I had so I had no reason to question it. 

 

I may be able to find my old 68hc11 dev board and get a sample output of the same data from this code.

I will review the spec too and see if it gives me any insights. 

 

On we go.

 

Link to comment
Share on other sites

From what I can see this file complies with DATA record type "00" 

 

image.thumb.png.a6ef83b086bd431c7c1025c1d160ead0.png

 

 

Randy's code seems to be the simplest version that only handle record type 00  and 01. 

 

"Special names are sometimes used to denote the formats of HEX files that employ specific subsets of record types. For example:

  • I8HEX (aka HEX-80) files use only record types 00 and 01"

 

https://en.wikipedia.org/wiki/Intel_HEX

 

Link to comment
Share on other sites

4 hours ago, Vorticon said:

The data length entry is half of what it should be.

Record type 0 has a byte sized count so it's length is limited to 255.

4 hours ago, Vorticon said:

Also it looks like you are using a field of 4 for each data item where it should be only 2.

Randy's encoder is reading memory a byte at time and outputting the byte value to the record. 

4 hours ago, Vorticon said:

I assumed that each data record will be on a separate line but can't find documentation on this. If not I will need to make modifications to my conversion program...

From Wikipedia:

"Programs that create HEX records typically use line termination characters that conform to the conventions of their operating systems. For example, Linux programs use a single LF (line feed, hex value 0A) character to terminate lines, whereas Windows programs use a CR (carriage return, hex value 0D) followed by a LF."

Link to comment
Share on other sites

On 1/20/2024 at 9:17 AM, TheBF said:

I see he does the checksum computation at the byte level by taking each byte in a integer adding them together and adding that sum to the CHKSUM.

DUP >< FF AND  OVER  FF AND +  CHKSUM +! 

 

My mistake.  Summing these bytes is used only to initialize the CHKSUM variable.

As I built the loader program I began to understand what Randy's code was doing on the encoding side.

 

The code above was used for an integer argument. It is split into two bytes and the bytes are added together. 

The resulting byte  is then added to the checksum. 

 

34 years ago I didn't take the time to figure this out. I just used the program. 

 

In CAMEL99 Forth this becomes:    SPLIT +   :)    

 

(SPLIT is  a code word that I made to solve some problem in the Graphics code way back when.

 It has a reciprocal called FUSE, which fuses two bytes into an integer.)

Edited by TheBF
Understanding
Link to comment
Share on other sites

While looking for something else I found this question by @FarmerPotato

 

"Does anyone know of a FORTH implementation of the TI tagged object file loader?

I found some old assembly code for a game. It would be nice if there were a way to use the assembled code so far in  FORTH wrappers. 

And continue to develop in FORTH. Changes to the assembly code would be assembled outside, then imported to the project. Ultimately the whole thing gets BSAVEd and run out of a FORTH prompt." 

 

Well my latest linker seems more practical and I can save a binary program that also loads LOW ram with the set of files so I think I could get this going.

Do you still have those files Erik? 

 

  • Like 1
Link to comment
Share on other sites

Here is a version of of LOADHEX that calculates a checksum as the file records are parsed and compares it to the checksum at the end of each record. 

The checksum addition is embedded in each parsing word that cuts the record string and converts to a number. 

 

If you type CHECKSUM OFF the loader will ignore the checksum byte at the end of each record.

CHECKSUM ON   will cause the program to halt with an error and a line no. if the checksums do not match. 

 

The MaxForth encoder seems to do something tricky. I have not figured out if it is compliant with the checksum specification

Randy's code adds each binary byte to a variable.

But the value it puts at the end of each record is that total, taking only the lower order bits , NEGATE that byte and mask with >FF. 

(see CHKSUM@ below) 

 

Anyway this loader uses what it was given. I will review the spec. and see if there is something wrong.

(I suspect Randy was smarter that I am in this area) :)

 

 

Spoiler
\ HEXLOADER.FTH  loads intel hex files into memory   Fox 2024 

INCLUDE DSK1.TOOLS 
INCLUDE DSK1.ANSFILES 
INCLUDE DSK1.VALUES 

VARIABLE CHKSUM     \ Accumulator for to compute check sum
VARIABLE CHECKSUM   \ flag:. halts on checksum error if CHECKSUM=TRUE

HEX 
\ renamed for clarity of purpose 
: LEFT$  ( addr len n -- addr len' ) NIP ;
: RIGHT$ ( addr len m -- addr' len') /STRING ;

: CHOP  ( addr len n -- addr1 len1 addr2 len2 )
    >R                 \ we need n twice, use return stack 
    2DUP  R@ LEFT$     \ substring 
    2SWAP R> RIGHT$    \ "RIGHT$" is remainder
    2SWAP ;            \ substring back on top
;

: $VAL  ( addr len -- n) NUMBER? ABORT" Bad number" ;

\           *** data management tags ***
\ Words that are in angle brackets operate on a string.
\ They take a part of the string convert it to a number
\ and leave the remainder of the string on the data stack 
\ for the next function to use. 

\ aborts if start code not found 
: <?START>  ( addr len -- addr' len') 
   1 CHOP DROP  C@ [CHAR] : <> ABORT" Start code not found" ; 

\ field parsers update the chksum variable
: <RECLEN>   ( addr len -- C ) 2 CHOP $VAL DUP CHKSUM +!  ;
: <ADDRESS>  ( addr len -- n ) 4 CHOP $VAL DUP SPLIT + CHKSUM +! ; 
: <RECTYPE>  ( addr len -- C ) 2 CHOP $VAL DUP CHKSUM +! ;
: <DATABYTE> ( addr len -- C ) 2 CHOP $VAL DUP CHKSUM +! ;


\ This is the last tag. It consumes the string. Returns file's chksum
: </CHKSUM>  ( addr len --  n ) $VAL ; 

0 VALUE #1  \ file handle holder 

: OPEN     ( fam -- )  OPEN-FILE ?FILERR ;
: READLN   ( addr -- addr size ?) DUP 80 #1 READ-LINE NIP 0=  ;

: <DATALINE> ( addr len -- ) BOUNDS DO  <DATABYTE> I C!  LOOP ;

\ This matches the checksum calc used by MaxForth's intel HEX save. 
: CHKSUM@  ( -- c) CHKSUM 1+ C@ NEGATE 0FF AND ;

: .DEC        BASE @ >R  DECIMAL  .  R> BASE ! ;

: ?ABORT  CHECKSUM @ ABORT" LOADHEX halted" ;

: ?CHECKSUM  ( n -- ) 
   CHKSUM@ <> 
   IF  
      CR ." >>> Checksum error Line: " LINES @ .DEC
      ?ABORT
   THEN  ;

: LOADHEX ( path$ len -- )
    LINES OFF 
    HEX 
    DV80 R/O OPEN TO #1 
    BEGIN 
       PAD READLN  ( -- addr len ?)
       LINES 1+!       
    WHILE ( read=true)
       CHKSUM OFF 
       <?START>      \ TEST for start code. Abort if wrong
       <RECLEN>  >R  \ use this later 
       <ADDRESS> >R  \ use this later ( r: -- len addr)
       <RECTYPE> 0=  \ 0 means a data record  
    WHILE     
       R> R>       ( -- addr len )    
       <DATALINE>  \ parse the data and write to memory 
       </CHKSUM>   \ clean up what's left. Return file line chksum 
       ?CHECKSUM   \ compare to computed chksum from file load
    REPEAT 
     UNLOOP        \ first time I ever used this word :-)
    THEN 
    2DROP
    #1 CLOSE-FILE ?FILERR
;

\ HEX 2000 2000 FF FILL \ for testing 
CHECKSUM ON 

 

 

  • Like 2
Link to comment
Share on other sites

Back 2021 I needed a way to capture text output from programs. 

I threw something together but it was not ideal. For example if you sent strings to the screen without a CR now and then it would fail. 

 

This updated version is more robust and bettered factored. 

>> If you have a Camel99 system disk, please replace DSK1.OUTFILE with this file. 

 

Of note to the student of Forth on TI-99, I am accessing the PAB, in VDP memory, just as easily as CPU RAM by using the VDP operators V@ V!  VC@ and VC!.

The do the same things as their regular Forth namesakes. 

The various fields of PAB are accessed referenced to the "SELECTed" file PAB with a syntax using the active PAB with a field selector word.

The field selector simply adds an offset to the base address of the PAB.  These are the words that have square brackets.

 

 To use this file you INCLUDE DSK1.OUTFILE  first.  Then any program loaded afterwords will use the dual output versions of the text output words.

If you MAKE-OUTPUT or OPEN-OUTPUT (append mode)  the program text will go to the screen and echo to the file. 

Kernel words that use the kernel output words will not echo to the file. 

 

In future I might patch the kernel words which would make everything to echo but at this time I don't need that. 

 

 

Spoiler
\ OUTFILE.FTH   echo screen output to text file      May 2021  Brian Fox
\ updated Jan 2024 

\ Method: Write data directly into PAB file buffer
\ Use the PAB char count in the PAB as pointer into the PAB when we write.
\ ie: the buffer address= [PAB FBUFF] V@  + [PAB CHARS] VC@ 
\ Only write to disk when CR is encountered or if buffer will overflow.
\ No control characters allowed. Use spaces for DV80 files

\                   *** THIS VERSION WORKS **** 
NEEDS WRITE-FILE  FROM DSK1.ANSFILES
NEEDS VALUE       FROM DSK1.VALUES
 
DECIMAL
0 VALUE OUTH   \ output file handle
VARIABLE FOUT  \ byte counter for outfile 

: MAKE-OUTPUT ( a u -- ) \ *G creates a new output file
    DV80 W/O CREATE-FILE ?FILERR  TO OUTH 
    FOUT OFF ;
 
\ : W/A   APPEND FAM @ ;  \ Moved to DSK1.ANSFILES
: .OUT  ( -- ) FOUT @ U. ." bytes output"  ; 

: OPEN-OUTPUT  ( a u -- ) \ open output file in APPEND mode
    OUTH ABORT" Output file is already open"
    DV80 W/A OPEN-FILE ?FILERR  TO OUTH 
    FOUT OFF ;
 
: CLOSE-OUTPUT ( -- )
    OUTH CLOSE-FILE DROP   0 TO OUTH 
    .OUT ;
 
: WRITE-PAB ( handle -- ) SELECT  3 FILEOP ?FILERR ;

: [PABCHARS]+! ( n -- ) [PAB CHARS] VC@  +  [PAB CHARS] VC! ; 
: [OUTBUFF]    ( -- Vaddr) [PAB FBUFF] V@   [PAB CHARS] VC@ + ;

: FLUSH-BUFFER ( -- ) OUTH WRITE-PAB    0 [PAB CHARS] VC! ;
 
: OVERFLOW?  ( n -- ?)  \ test n bytes will overflow buffer
    [PAB CHARS] VC@ +   \ add n to chars in the buffer 
    [PAB RECLEN] VC@ >  \ compare to the maximum size 
;

: >>OUT ( caddr len -- )
    OUTH 0= ABORT" Output file not open"
    OUTH SELECT 
    DUP OVERFLOW? IF  FLUSH-BUFFER  THEN 
    TUCK  ( -- len caddr len )         \ get a copy of the length
    [OUTBUFF] SWAP VWRITE              \ write string to PAB buffer
    ( len) DUP [PABCHARS]+!  FOUT +! ; \ update Char count
 
\ ==========================================
\ redefine standard output words to echo to file if output handle<>0
: EMIT  ( c --)   OUTH IF DUP HERE C!  HERE 1 >>OUT  THEN EMIT ;
: TYPE  ( a u --) OUTH IF 2DUP  >>OUT  THEN TYPE ;

: ."  (  ccc" -- )
    POSTPONE S" 
    STATE @ IF POSTPONE TYPE   EXIT   THEN TYPE ; IMMEDIATE

: SPACE   BL EMIT ;
: SPACES  ( n -- ) 0 MAX  0 ?DO  SPACE LOOP ;
 
: CR   ( -- )
    OUTH IF \ file is open
       [PAB CHARS] VC@  0= IF SPACE THEN FLUSH-BUFFER
    THEN CR  ;
 
\ number output with echo
: UD.  ( d -- ) <#  #S  #> TYPE SPACE ;
: U.   ( u -- ) 0 UD. ;
: .    ( n -- ) DUP ABS 0 <#  #S ROT SIGN  #> TYPE SPACE ;
 

 

 

OUTFILE

  • Like 1
Link to comment
Share on other sites

OH I do go down rabbit holes.

While doing some housecleaning I found a failed attempt to make timers.

I had to make it work. 

 

So these timers use the ISR to run a master 32 bit counter. 

When you create a timer is records 2 things:

  1. The number of seconds it will run before expiring
  2. The value of the counter + the number of seconds 

When you call the timer by name, it compares the master counter to the number it has stored inside of itself.

If the master counter is greater than it returns TRUE, meaning the timer expired. 

 

 

The advantage here is that you do RESET <TIMER_name> and then your program can do whatever is needed and test the timer when it can.

The overhead of the IST is almost nothing since it is just 3 instructions max + return. 

 

Here is an example program:

\ test
4 TIMER: Q 

: TEST 
    PAGE 
    RESET Q 
    BEGIN 
      BEGIN Q UNTIL           \ wait until  Q expires 
      CR ." 4 sec. elapsed " 
      CR
      RESET Q  
      ?TERMINAL               \ test for break key 
    UNTIL       
;

 

OK now I can get on with something important. 

Spoiler
\ ISR BASED TIMERS  

INCLUDE DSK1.LOWTOOLS  \ assembler, dump, Vdump, .S , elapse 

HEX 
: INSTALL  ( isr-addr -- ) 83C4 ! ; 

: STOPTIMER  0 INSTALL ;
 
  STOPTIMER  

\ 32 bit variable holds the master clock time
CREATE TIME  0 , 0 ,

\ ISR increments the 32bit variable every 16mS
HEX
CODE TIME++ ( -- ) 
  TIME CELL+ @@ INC,
  OC IF,
      TIME @@ INC, 
  ENDIF,
  RT,
ENDCODE

\ start/stop the ISR timer 
: RUNTIMER   ['] TIME++ INSTALL ;

\ debug words 
: TIME@   TIME 2@ ;
: .TIME   TIME@ UD. ;

: DU<  ( d d -- ?) ROT U> IF 2DROP -1  ELSE U<  THEN ;

\ reset a timer's using it's data field address 
: (TRESET) ( addr -- )
    DUP  >R             \ dup timer base address & Rpush 
    @ TIME@ ROT M+       \ read the timers' delay, add to TIME 
    R> CELL+ 2!         \ store new time in the timer 
;

DECIMAL 
: SECONDS  ( n --- n') 1000 16 */  ; \ convert seconds to ticks 

: TIMER: ( n )
    CREATE  
        SECONDS DUP ,  \ compile the time delay in ticks 
        TIME@ ROT M+   \ add delay to current time 
        , ,            \ compile the double into memory 

    DOES> ( -- ?)      \ return true if timer expired 
        CELL+ 2@  TIME@  DU< ; \ compare timer to master


\ reset a timer by name. state smart
: RESET ( timer )  
  '  >BODY 
  STATE @ 
  IF POSTPONE LITERAL  POSTPONE (TRESET) 
  ELSE  (TRESET)
  THEN ; IMMEDIATE  

\ reset master clock to 0 
: MASTER-RESET ( -- ) 0 S>D TIME 2! ;

\ disable the ISR before resarting Forth 
: COLD     STOPTIMER COLD ;

RUNTIMER 

 

 

  • Like 2
Link to comment
Share on other sites

Well the next demo I looked at was putting Forth code into SAMS RAM.

I had always been P___d-off that CREATE DOES> words did not work.

I finally figured out why.  

 

I had started with Mark's ( @Willsy )  excellent example from Turbo Forth. It allowed me to get my head around a method to do this. 

Mark's code uses the normal ':' and ';'  operators with some very clever use of COMPILE.

 

But you know, I had to try some ideas. I wanted to reduce the size of the headers and also move the ';' (EXIT) code over to the SAM memory.

So I made some ALC for FARCOL and FAREXIT.  This means that my 'FAR:' for SAMS is specific.

 

The reason why CREATE DOES> fails in my version is that I need to make  'FARCREATE' to be compatible with what I did. 

So I spent along time last week trying to fix something that wasn't broken, just not finished! :)

But at least I have what seems like a solid version for Camel99 Forth. Actually I have two. One is a TURBOFORTH compatible file and then my version. 

 

As a test I did this:

DECIMAL
240 CODEPAGE 
\ 1 SETBANK ( for TurboForth version) 
HERE 
: HELLO   CR ." Hello SAMS World!"  ;
HERE SWAP - . 

INCLUDE DSK1.ANSFILES
INCLUDE DSK1.DIR
INCLUDE DSK1.MORE 
INCLUDE DSK1.CATALOG 

 

So I still have to make FARCREATE but I think I have achieved my original goal of making things a bit smaller. 

\ SAMS CODE  TF    CF
\ headers  = 1290   850 
\ code     = 1684  1594 
\ total      2974  2444  

 

Spoiler
\ SAMSCODE.FTH                for Camel99 Forth  Brian Fox
\ Code in SAMS memory based on concept in TurboForth by Mark Wills
\ Ported to Camel99 Forth with changes Oct 13, 2021,

\ Concept:
\ FAR: word headers are in the normal Forth memory space so all SAMS words
\ can be found.
\ FAR: word data structure has two extra fields
\ <link> < HEADER> <imm> <len NAME..> <FARCOL> <BANK#> <IP>

\ FAR: compiles a "fat" header that contains SAMS BANK# and SAMS IP
\  <LINK> 
\  <PRECENDCE> 
\  <NAME> 
\  <CODEPAGE> \ extra field
\  <SAMSPFA>  \ extra field 


\ ;FAR compiles FAREXIT in SAMS memory, not in RAM to save space.

\ Compile time check: ;FAR tests end of SAMS memory

\ HISTORY
\ Update Nov 2022: removed array of SAMS DP variables.
\ - Each SAMS page uses last memory cell to hold its own DP.
\ - Can now compile code to any SAMS page.
\ - You must use <1st> <last> CODEPAGES to initialize SAMS code pages 
\ Feb 2024: Pass codepage via Rstack to CMAP, cleanup & testing 


NEEDS TRANSIENT FROM DSK1.TRANSIENT
TRANSIENT
NEEDS MOV,  FROM  DSK1.ASM9900

PERMANENT
NEEDS DUMP      FROM DSK1.TOOLS
NEEDS SAMSINI   FROM DSK1.SAMSINI  \ common code for SAMS card

HERE
HEX
\ **************[ CHANGE CSEG to your requirements ]******************

HEX              3000 CONSTANT CSEG      \ CODE window in CPU RAM

\ ********************************************************************

\ SAMS memory addresses for code
          CSEG 0FFE + CONSTANT SAMSDP    \ variable at end of SAMS page
          CSEG 0F00 + CONSTANT SAMSEND   \ leave room for scroll buffer      
4000 CSEG 0B RSHIFT + CONSTANT CREG      \ compute CSEG SAMS register
     CSEG 0C RSHIFT   CONSTANT PASSTHRU  \ default RAM page

VARIABLE SAVHERE   \ temp holder for RAM Dictionary pointer
VARIABLE BANK#     \ last SAMS bank# selected
VARIABLE CPAGE     \ active code page used for compiling
CREATE CODEPAGES 0 , 0 ,   \ valid CODEPAGES 

HEX
\ **LEAF SUB-ROUTINE**
CREATE R>CMAP ( -- ) ( R: page# -- )
      R0 RPOP,
      R0 BANK# @@ MOV,   \ update the last bank used
      R0 SWPB,           \ swap bytes
      R12 1E00 LI,       \ set SAMS card CRU address
      0 SBO,             \ turn on the card
      R0 CREG @@ MOV,    \ map it
      0 SBZ,             \ turn off card
      RT,

CODE CMAP  ( page# --) \ Forth word to map SAMS pages
      TOS RPUSH,    \ need parameter on Rstack
      R>CMAP @@ BL,  \ call it
      TOS POP,      \ refill TOS
      NEXT,
ENDCODE

\ run time executor for SAMS colon words.
CREATE FARCOL
     IP RPUSH,
     W IP MOV,            \ IP = DATA cell of this word
     BANK# @@ RPUSH,      \ Save active code page
     *IP+ RPUSH,          \ get codepage to rstack, autoinc IP 
      R>CMAP @@ BL,       \ call R>CMAP (uses RSTACK parameter)
     *IP+ IP MOV,         \ get SAMSDP & set as IP, autoinc IP  
     NEXT,

CODE FAREXIT             \ exit for SAMS word
     R>CMAP @@ BL,       \ restore old codepage & map it in
     IP RPOP,            \ Regular FORTH EXIT
     NEXT,
ENDCODE

\ \\\\\\\\\\\\\\\\ code words end  //////////////////

\ change dictionary pointer to SAMS memory
: SAMSDP! ( addr -- ) 
     HERE SAVHERE !     \ save FORTH here
     ( samsdp) DP !     \ set Forth DP to SAMSDP
;

: FAR: ( -- ) \ special colon for words in FAR memory
     !CSP
     HEADER             \ compile Forth header with name
\ RUNTIME ACTION 
     FARCOL ,           \ compile the new executor as CFA

\ compile code page and SAMSDP for FARCOL to use at runtime 
     CPAGE @ DUP ,      \ compile codepage as the DATA field
     CMAP               \ map in the SAMS page to compile code
     SAMSDP @ DUP ,     \ compile SAMSDP 
     SAMSDP!            \ and switch dictionary to SAMSDP     
     HIDE 
     ]                  \ turn on the compiler
;

: ;FAR ( -- ) \ end SAMS compilation. *NEW* compile time memory test
     POSTPONE FAREXIT    \ compiles at end of SAMS code
     POSTPONE [          \ turn compiler off
     REVEAL ?CSP
     HERE DUP SAMSDP !   \ update HERE for this bank, keep a copy
     SAVHERE @ DP !      \ restore DP to CPU RAM
     SAMSEND > 
     IF 
       CR ." >> Page " CPAGE @ DECIMAL . ." full"
       ABORT 
     THEN 
; IMMEDIATE

: CODEPAGE ( bank# -- ) \ select SAMS page for compiling
  DUP CODEPAGES 2@  1+ WITHIN 0= ABORT" Not a code page" 
  CPAGE ! ; 

HEX
\ Initialize the SAMS memory that we want to use for CODE 
\ ** USE THIS COMMAND ONLY ONCE OR MACHINE WILL CRASH ** 
: CODEPAGES ( 1st last -- ) 
     2DUP CODEPAGES 2! 
     1+ SWAP 
     DO 
       I CODEPAGE
       I CMAP
     \  I . CSEG 1000 FF FILL \ for debugging ONLY 
       CSEG SAMSDP !     \ INIT the local CSEG DP variable to start of CSEG    
    LOOP  
    CODEPAGES @ DUP CODEPAGE CMAP  \ return to RAM memory page
;

: >SAMS  CPAGE @ CMAP ;
: >RAM   PASSTHRU CMAP ; 

>RAM 
: ;   
     BANK# @ PASSTHRU = 
     IF POSTPONE ;  
     ELSE POSTPONE ;FAR 
     THEN ; IMMEDIATE 

: :  BANK# @ PASSTHRU = IF :   ELSE  FAR:  THEN ;


                            DETACH 
HERE SWAP -
DECIMAL CR . .( bytes)

240 255 CODEPAGES \ 16 pages=64K of SAMS space 

 

 

  • Like 2
Link to comment
Share on other sites

Thought I would beat this thing up.  I used the Ultimate Benchmarks 1Million Nesting test with a twist.

Every call is too a different SAMS page. Totally brutal.

 

So it looks like calling a paged Forth word takes about 4 times longer than a regular call. 

Not too bad. I removed the instructions that test if the SAMS page is already in memory because it added 2 instructions to skip 6 instructions.

In a test like this it makes things even slower. It might still be worth having for code that uses lots of stuff in the same block. 

 

I am going to see if it compiles and runs on real iron.  <scary music here>

 

Here are the results

\ Normal Forth nesting   
\  TurboForth 1.21     Nesting 1Mil  2:29
\  Camel99 Forth ITC   Nesting 1Mil  2:30
\  Camel99 SAMS card   Nesting 1Mil ~10:00  !!!

 

Here's the code:

Spoiler
\ Source:  https://www.theultimatebenchmark.org/

\ Normal Forth nesting   
\  TurboForth 1.21     Nesting 1Mil  2:29
\  Camel99 Forth ITC   Nesting 1Mil  2:30
\  Camel99 SAMS card   Nesting 1Mil ~10:00  !!!

>RAM 
INCLUDE DSK1.ELAPSE 

>SAMS 
DECIMAL 
240 CODEPAGE  : BOTTOM ;

241 CODEPAGE : 1st BOTTOM BOTTOM ;  
242 CODEPAGE : 2nd 1st 1st ;      
243 CODEPAGE : 3rd 2nd 2nd ;
244 CODEPAGE : 4th 3rd 3rd ;   
245 CODEPAGE : 5th 4th 4th ;      
246 CODEPAGE : 6th 5th 5th ;
247 CODEPAGE : 7th 6th 6th ; 
248 CODEPAGE : 8th 7th 7th ;      
249 CODEPAGE : 9th 8th 8th ;
250 CODEPAGE : 10th 9th 9th ;
251 CODEPAGE : 11th 10th 10th ;   
252 CODEPAGE : 12th 11th 11th ;
253 CODEPAGE : 13th 12th 12th ;     
254 CODEPAGE : 14th 13th 13th ;   
255 CODEPAGE : 15th 14th 14th ; \ Highest page with 1M SAMS
241 CODEPAGE : 16th 15th 15th ;     
242 CODEPAGE : 17th 16th 16th ;   
243 CODEPAGE : 18th 17th 17th ;
244 CODEPAGE : 19th 18th 18th ;    
245 CODEPAGE : 20th 19th 19th ;   


:  1MILLION  CR ."  1 million SAMS nest/unnest operations"  20th ;

 

 

Edited by TheBF
typo
  • Like 1
  • Haha 1
Link to comment
Share on other sites

OK here's a lesson for stupid people like me. 

Make sure you understand what you are testing. 🤦‍♂️

 

I have been fighting forever to try and duplicate the seamless functionality of Mark's code with the changes I made.

I could never get CREATE to work properly. 

 

Here is the test code. Looks simple enough.

HEX 
CREATE DATA   AAAA , BBBB , CCCC , DDDD ,

I thought somehow magically Mark's code was putting those HEX numbers into SAMS memory. 

I NEVER CHECKED that this was true!   The problem seems to have been that my "semi-colon" for SAMS was not doing one magic thing. 

I thought because I was so clever and using the return stack to nest SAMS calls, that I didn't need one line of code. 

 

So here is the @#$!@ word that has taken years off my life. :) 

: ;FAR ( -- ) \ end SAMS compilation. *NEW* compile time memory test
    POSTPONE GOTO  SAVHERE @ ,  \ get Forth IP back home  *CRITICAL* 
    
    HERE DUP ?FULL SAMSDP !     \ update HERE for this bank, keep a copy
    SAVHERE @ DP !              \ restore DP to CPU RAM
    REVEAL 
    POSTPONE FAREXIT            \ restore previous page and exit 
    ?CSP
    POSTPONE [
; IMMEDIATE

 

This is complicated because the word is immediate.  All the stuff that says POSTPONE doesn't happen until you make new word and that word RUNS.

That first line with the GOTO in it restores the Forth interpreter pointer to normal RAM at RUNTIME. It happens when the SAMS word run & finishes.

 

All the rest the normal code runs when you compile the new word. Meaning at COMPILE TIME. 

But the order is critical because the dictionary pointer has to be correct for each situation.  My head hurts.

 

I will put the new version up in the repository later today as  LIB.ITC\DSK1.SAMSCODE 

 

The changes, for reference are:

  1. Remove the need for a separate stack. Use the return stack to nest SAMS pages 
  2. Remove  the array of dictionary pointers. Record SAMS dictionary pointers in the last cell of each page. 
  3. Replace DOCOL with FARCOL that pushes the IP and pushes the current SAMS page and  reads two extra data fields
  4. Replace EXIT with FAREXIT that pops the save SAMS page and maps it into memory, and then does Forth EXIT 
     

My idea is to treat CODE and DATA separately in SAMS so this system forces you to decide on a range of SAMS pages for CODE.

I chose the upper 64K. ( you could initialize more if you want, but keeping them contiguous is easiest) 

The rest can be used for data. I have a pretty good virtual memory system for that. 

 

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

  • 2 weeks later...

One of the problems with the Forth 94 Standard and more so in 2012, is the stuff that is dependent on other stuff. 

 

For example the word REFILL which behaves one way for reading the console device, (like QUERY with a returned flag)

but it you have the optional "File wordset" it has to behave differently. 

 

"When parsing from a text file, if the end of the parse area is reached before a right parenthesis is found,

  refill the input buffer from the next line of the file, set >IN to zero, and resume parsing,

  repeating this process until either a right parenthesis is found or the end of the file is reached." 

 

So here are the results of that revelation and today's work.

 

** You can add the MULTILINE file to your Camel99 Forth system disk if you have room. **

 

Edit: Fixed a bug in this file. See next post. 

                                                                      
 

 

 

 

  • Like 2
Link to comment
Share on other sites

Deeper testing in the real world showed that my multi-line comment worked fine as long as we don't put other brackets into the mix.

I needed to add an extra test for the end of line to trigger a refill. That seemed to fix it. 

 

 I also use the new standard word PARSE-NAME. I tend to forget I have it.

 It is just

 : PARSE-NAME  ( -- addr len)  BL PARSE-WORD  ;

 

but it loads from DSK1.SYSTEM at boot time and it  takes 2 bytes less if you use it in definitions. 

And I realized I didn't need anything as fancy as COMPARE since I am looking for one character. 

 

New version

Spoiler
\ MULTILINE.FTH  comments can extend across many lines 21 FEB 2024
HERE
DECIMAL
\ Standard: 11.6.2.2125 FILE EXT 
: REFILL ( -- ?) \ len=0 means no input
  TIB DUP    
  SOURCE-ID @ ( console=0, file=1,2 or 3)
  IF  ( text from file )  
     2 FILEOP ?FILERR FGET 
  ELSE ( text from console ) 
     80 ACCEPT 
  THEN 
  ( -- addr len)
  DUP IF  
       'SOURCE 2!  >IN OFF  TRUE
        EXIT  
  THEN NIP 
;

: (
    BEGIN
      PARSE-NAME ( -- adr len) 
      IF   C@  [CHAR] ) <> 
      ELSE TRUE  \ no text, force a refill 
      THEN 
    WHILE 
      REFILL   
    REPEAT 
;  IMMEDIATE
HERE SWAP - DECIMAL . .( bytes)

 

 

 

Please delete the previous file and replace with the TI-99 DV80 file below.  

 

MULTILINE

  • Like 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...