An RPG on Uzebox

Use this forum to share and discuss Uzebox games and demos.
User avatar
nicksen782
Posts: 714
Joined: Wed Feb 01, 2012 8:23 pm
Location: Detroit, United States
Contact:

An RPG on Uzebox

Post by nicksen782 »

Check out the game page!
https://nicksen782.net/r_uzerpg.php
... There are some short demo videos there too.

// OVERVIEW
I have been building tool and doing tests for this since April. This is not intended to be a clone of an existing game. Not a faithful clone anyway.
It is a merging of several game ideas that I liked. One of the more important parts to an RPG is the story. Story means dialog and dialog means bytes.
A whole fontset (64 font tiles) is 4096 bytes. Right now the Uzebox API Print functions work with a FLASH-based fontset.
In fact, for development/debugging I am going to continue to use a FLASH-based tileset. However, I would like to be able to change colors which means I need ram tiles.
I have roughly 200 bytes free RAM right now with 36 ram tiles. I only have 4 sprites configured but that is due to the way the game will work. It was easier if the player character is a sprite.

// RAM TILE SPRITES
I did experiment with ram tile sprites and was successful. The cost is the cost of blitting that sprite plus however many tiles are needed for the source of that sprite.
For example, a 2x2 sprite would need 4 sprites to blit and 4 sprites to source. So, if a 2x2 can take a max of 9 to blit (overlap on both x and y) then the cost is 12 tiles.
However, if you draw the same sprite somewhere else also then the cost is just the extra blitting cost since you only pay the source cost once.
This is expensive but does allow for a possibility of sprite tricks where you change the sprite source to ram and then work with it.
This would be an impromptu and quick thing since even though I have so few sprites I really am using those ram tiles.

// PSEUDO SPRITES
To save on the cost of the ram tile source and blit I came up with a ram tile solution that doesn't use sprites but appear like sprites.
These sprites (as of now and likely to stay this way) remain aligned on x and y just like any other tile. In fact, I'm just drawing ram tiles with SetRamTile().
The trick is the blitting. Blitting is replacing a "translucent" color with the color whatever is directly "behind" it.
This is why I needed to know what the active tileset was since I needed to directly read tile data in order to do this. The result is a ram tile pseudo-sprite.
My pseudo-sprites come in two-frame and one-frame types. The two-frame ones are just two frames. The one-frame ones use a software x-flipping.
In many games a character appears to "dance". They aren't dancing. They are being drawn and then being drawn again mirrored. This is a common trick to reduce the tile count in games. It looks like the character is moving. It isn't of course but it looks that way.
A pseudo-sprite cannot take advantage of the kernel's built-in x-flip functions (PLEASE CORRECT ME IF I AM WRONG.) So, I wrote one in C.
You would think that this would be terribly slow and it likely is but the game did not slow down. I didn't' notice additional lag.
The x-flipping happens a couple times per second so if it were to slow things down I think I would have noticed (PLEASE FEEL FREE TO CORRECT MY UNDERSTANDING ON THIS.)
So, for the ram tile pseudo sprites I have fixed tilemaps and disabled tile de-duplication when creating the tileset for these.
Why? Because de-duplication leads to some unpredictability in tile ids.
I actually have a method of drawing tiles AND the tilemap where the map itself is in SPIRAM and read from for each draw. I may consider this for the pseudo ram tile sprites in the future.

// SPI RAM
This uses the SPI RAM heavily. Not only is much of the graphics in SPI RAM but music is streamed from there as well. Thus far this is working out. I have a 16 byte music streaming buffer.
The SPI RAM is plenty fast. If you reduce the number of rendered video lines then it is even faster (just do it for a quick moment while reading in the more heavy cases.)
The SD CARD is just used as mass storage and contains the file that gets loaded into SPI RAM. I intend to do game saves to the SD CARD and I may move dialog there too.

// RAM TILES, RAMTILE MAPS, FLASHTILE MAPS STORED IN SPIRAM GRAPHICS
Due to the speed of the SPI RAM I can store both the tileset AND the tilemap in SPIRAM. I only need to cache the ram tiles. I actually keep the map in SPI RAM too.
So, a screen could use FLASH tiles and be huge. For instance, a conservative 28x28 tile screen takes 784+2 bytes for the tilemap.
Screen maps should not be stored in FLASH. I store them in SPIRAM in binary. Using a system of offsets I can point to any of those maps and then draw them into VRAM.
I can do the same thing where both the TILE and the MAP are stored in SPIRAM. In this case I can use up to my ram tile limit in unique tiles and draw whatever I want.
In fact, I have experimented with combining FLASHTILE and RAMTILE maps together (draw one and then the other) and can have both on screen.
It just works well. The only hard part is creating the system to manage it all.

// SCREEN DRAWING AND "SCROLLING"
I am not using smooth scrolling. This removes some kernel complexity and it seems fine this way.
I still do want to make the screen look like it is shaking (for example upon an attack.)

// DIALOG
RPGs are usually big on dialog and story and I knew that this would likely be the most critical feature of the game.
Each dialog has a "Chathead" available. It is paletted on the fly so there is a base tileset for a particular chathead and then a palette is used to replace the source colors.
This really is a seek out and replace operation. I change the ramtile itself which is how the Chathead is brought it.
Upon changing the source no further repaletting needs to happen (although it could if you wanted.)
A chathead is a 2x3 tilemap. A map would take 8 bytes and each Chathead frame would need 6 tiles. However, these tiles ARE de-duplicated. So far, for three 2x3 frames I only need 10 ram tiles.
An 11th tile is reserved but interpreted like data. The first 32 bytes are reserved for palette data and the last 32 bytes actually store the tilemaps for each frame.
I actually still have some bytes left over too!
So, a loaded Chathead with 3 frames takes 11 ram tiles. That leaves me with 25 ram tiles remaining. I really wanted to have the fontset in ram tiles so that I could adjust colors and to save the 4k of FLASH.
Most English phrases typically use 18 - 23 unique letters. I have 25 available. The trick is to make sure that you limit the unique characters in whatever message you create.
The length of the message is not important here other than a length choice (of which I have alloted up to 256 characters currently.)
So, for each separate dialog you need to load a custom fontset based on the unique letters of the dialog and change the colors of each tile on the fly.
This does not take as much time as it sounds like it would. Take a look at the demos.
The next step is handling the drawing of the text. I wanted text to be drawn out quickly letter by letter (not a page at once).
So, I keep track of x and y. Once y gets low enough I quit changing it and just shift vram up instead. It looks like scrolling.
Each separate dialog can load custom font colors and a customized Chathead. Currently, I am thinking of doing this game WITHOUT a "silent protagonist".
Perhaps the player can choose a Chathead for themselves at the beginning of the game.

// HELP?
Size of a tile:
I like mode 3 and I have written a large number of tools for it. However, if there was one thing that I would change it would be the overall color palette.
There are lots of colors available and many look very similar. I'm not doing a high-resolution game here.
I would assume that this would make the tile size (in bytes) smaller. That means more unique tiles and more ram tiles. I am really pushing my ram tiles.
A few more at the cost of colors I won't be using anyway would be awesome.

// VERSIONS:
Presently there are only two versions. V1 and the in-progress V2.

V1 is largely a demo of several early key parts of the game engine:
Load/draw the screen.
Scroll the view (like a pseudo camera)
Load/draw NPC graphics that are specific to a screen.
Animate those graphics.
Handle the transistion between screens (border exits.)
Handle the transitions into "doors". I refer to these as "teleports". Basically a square on the map that you walk on and then end up somewhere else.
Streaming music and changing (or not) the currently playing song based on which screen you are on.
Be sure to check out the Tavern.
Be sure to check out the flower screen found two screens down from the start screen.

V2 is still in progress but has additional features:
Improved NPC graphics system.
Improved screendata system (has a GUI now. It was a nightmare in V1 with all the manual data editing, especially for border exits and teleports!)
Full RAM-based animations.
Screen maps can be sourced from binary in SPIRAM.
The initial and subsequent data loads are faster.
Dialog. You can now "speak" to NPCs. Each side of the conversation (can be more than 1 side) can have a custom colored font and custom colored avator ("Chathead").
Dialog can be displayed normal, fast, or immediate.
User avatar
D3thAdd3r
Posts: 3221
Joined: Wed Apr 29, 2009 10:00 am
Location: Minneapolis, United States

Re: An RPG on Uzebox

Post by D3thAdd3r »

Hell yeah, this is exciting! Sounds like you are really going all out on this one and using some advanced approaches to it all. Utilizing the SPI Ram to the max strikes a particular chord with me, it is something that really needs to be leveraged proper like you are here.
nicksen782 wrote: Tue Jul 17, 2018 10:32 pm I still do want to make the screen look like it is shaking (for example upon an attack.)
I suppose this is required to add some action to the battles. Would depend if you decided to continue development in Mode 3, and later had intentions of converting to a different mode at the end(one of Jubatian's would fit the bill). Either way having the core of the game working seems the important part. I think you could add in some shake by twiddling where the first line is drawn and also messing with the "center adjustment". I forget if center adjustment is still there or if it was removed, but it would be easy to add back. The thing is that it was always implemented as a compile time flag, where it would need a variable to do what you want there. Pretty sure there is a free register to keep around in Mode 3, so the modifications shouldn't be too crazy.
nicksen782 wrote: Tue Jul 17, 2018 10:32 pm A pseudo-sprite cannot take advantage of the kernel's built-in x-flip functions (PLEASE CORRECT ME IF I AM WRONG.)
I don't know enough on the design to know either way. I think it would at least be tough, if not impossible, to use the stock sprite blitting since you are definitely going to be doing user ram tile type stuff here.
nicksen782 wrote: Tue Jul 17, 2018 10:32 pm // HELP?
Size of a tile:
I like mode 3 and I have written a large number of tools for it. However, if there was one thing that I would change it would be the overall color palette.
There are lots of colors available and many look very similar. I'm not doing a high-resolution game here.
I would assume that this would make the tile size (in bytes) smaller. That means more unique tiles and more ram tiles. I am really pushing my ram tiles.
A few more at the cost of colors I won't be using anyway would be awesome.
You could save some space in a way, but you can't save space on the flash tiles in Mode 3 nor gain more ram tiles(except, by saving on ram tiles used as source data). So you could bitpack say 4 pixels into a byte, and then 2 bytes represent a 8 pixel row, then 16 bytes is 1 sprite source ram tile. But when you blit that, it will still take a whole ram tile, since the scanline renderer can only deal with 8bpp data. It sounds like you are already deep into the ram tile tricks, but some of the items here are less tricky and more just a fundamental limit of having an 8bpp mode(where other types of modes have their own fundamental limits). I used 32 bytes per sprite for the Mega Bomber demo floating around, which means I decompressed that data and also applied some palette changes, and the blitter was in C. In Lolo before going to SD, I stored data in 2bpp, and decompress that with a C blitter and a palette. Point being that at the time I assumed it would be too slow but tried it anyway, and it worked for those. I have no memory exactly how much slower it was, but it was not the orders of magnitude slower like I had expected(MB in particular, that is a lot of sprites moving with AI running and no crashing/stutters). Ultimately it sounds like you are already pushing on the boundaries of Mode 3, where if you don't crash into the boundaries of a different mode, it would well be worth considering. Myself, if I am on a roll, I do not side track for such things until I am forced to...it sounds like you are on a roll here 8-)
User avatar
Jubatian
Posts: 1561
Joined: Thu Oct 01, 2015 9:44 pm
Location: Hungary
Contact:

Re: An RPG on Uzebox

Post by Jubatian »

Huh, this is looking quite nice, really a new genre of games become possible with that SPI RAM! :) (I knew, that doesn't change the fact that it seems like I wouldn't be the first to do something about it, then!)

Some suggestions then, hope it can help solving some problems.

I see you are using RAM tile effects heavily, and maybe even more as you progress forth. It might be advantageous for you to use SPRITES_AUTO_PROCESS to disable the kernel's normal sprite processor (look at the descriptions in videoMode3.def.h). If you do this, of course the kernel's normal sprite routines are gone, you get a blitter (BlitSprite) instead, a fast assembler blitter which can do X/Y flip. RAM tile allocation still goes as normal (but keep in mind that SPRITES_VSYNC_PROCESS is also zero then, you need to do a bit of work to manage VRAM restoring).

To shake the screen, like Lee mentioned, you may use SetRenderingParameters(), moving around the start of the frame, although be vary that it isn't exactly interrupt-safe (that's something what really could received a fix). Calling it in every frame, if it gets timed poorly related to the video frame, might produce some losses of sync.

The center adjustment was always a compile-time constant (a define), so you can't use it for shaking even in modes which have it, basically it is only useful for some personal messing around. You need to recompile the game when you change it (so you can compile a version of a game yourself which fits better on your TV if necessary). So using the non-scrolling Mode 3, you are limited to vertical shaking using SetRenderingParameters().

The speed of the blitter isn't noticable as long as your renders can finish within a frame. Having aligned sprites can help a lot in this regard, even the compiler might do a nice job optimizing loops with fixed iteration counts, so it may well happen that it appears just as fast as drawing an aligned sprite with the normal sprite engine. Assembling a sprite from four fragment (unaligned sprite) incurs a lot of overhead, and of course those loops necessarily have variable iteration counts, that's complex stuff. X flipping with a sane code shouldn't be any slower than a straight blit, it's just the matter of walking the source (or destiation depending on code) the other way. So I think it's okay to write such aligned renderers in C regarding performane, the optimizer can do a decent job on those.
nicksen782 wrote: Tue Jul 17, 2018 10:32 pm Size of a tile:
I like mode 3 and I have written a large number of tools for it. However, if there was one thing that I would change it would be the overall color palette.
There are lots of colors available and many look very similar. I'm not doing a high-resolution game here.
Not much can be done about that of course except for moving to a different video mode. But the problem is that 4bpp (16 color tiles) just isn't feasibly possible at 5.5 cycles / pixel (extended resolution Mode 3), the best I came up with on sketch is a 2bpp ROM tiles + 4bpp RAM tiles at this resolution. That means your background would have limited colors. And of course that mode currently doesn't exist, which is a real setback :P Otherwise you can get 6 cycles / pixel (original Mode 3's resolution) with Mode 13 (slow blitter), and if you really really don't need high-res, Mode 748 is already sitting in the main repo since a while! :)
User avatar
nicksen782
Posts: 714
Joined: Wed Feb 01, 2012 8:23 pm
Location: Detroit, United States
Contact:

Re: An RPG on Uzebox

Post by nicksen782 »

Oh! So there already is a built-in method for me to blit ram tiles against the background even if their aren't sprites? These are technically just tile maps that are ram tiles instead of flash. They remain aligned. So, I could just run the kernel's blitter ( BlitSprite() )? That means I can remove the C version that I wrote since it is a blitter and a someone complex x-flipper.

I saw this on: http://uzebox.org/wiki/Sprite_Techniques ... Does this do a whole map? I would think not.
BlitSprite(i, bt,(y<<8)+x,(dy<<8)+dx);

What is this for?
void BlitSpritePart(u8 ramtileno, u16 flidx, u16 xy, u16 dxdy);

Do you provide it a ram tile and a flash tile and a x/y coordinate? Blitting is taking one tile and placing it "on top" of another where you can see the under tile through "translucent" pixels, right?

I only have one actual sprite (the player for walking around.) Would I need to manually call the blitting there too? Actually, I only change the animation frame every few vsyncs so couldn't I skip all those blits if the blitting has already occurred against the sprite's ram tile and is already pointed to in VRAM?
I see you are using RAM tile effects heavily, and maybe even more as you progress forth. It might be advantageous for you to use SPRITES_AUTO_PROCESS to disable the kernel's normal sprite processor (look at the descriptions in videoMode3.def.h). If you do this, of course the kernel's normal sprite routines are gone, you get a blitter (BlitSprite) instead, a fast assembler blitter which can do X/Y flip. RAM tile allocation still goes as normal (but keep in mind that SPRITES_VSYNC_PROCESS is also zero then, you need to do a bit of work to manage VRAM restoring).
Perhaps I don't understand well enough. But this seems very useful to me. If those blit/flip routines are already available then I could save some flash by removing my c version (so, I could "buy" more flash tiles.")
User avatar
D3thAdd3r
Posts: 3221
Joined: Wed Apr 29, 2009 10:00 am
Location: Minneapolis, United States

Re: An RPG on Uzebox

Post by D3thAdd3r »

On BlitSprite and BlitSpritePart, I think there was a fairly recent clean up on Mode 3 by Jubatian. Maybe some internal things got renamed? The sprite techniques wiki entry was written quite a while back, though the concepts there are still valid.
User avatar
nicksen782
Posts: 714
Joined: Wed Feb 01, 2012 8:23 pm
Location: Detroit, United States
Contact:

Re: An RPG on Uzebox

Post by nicksen782 »

Do those functions require a sprite? Meaning part of the spriteStruct array.

If I could just do something like "Take this tile at position x y and this other tile (a ram tile) and combine them" then that would be ideal. Presently, there is a tilemap for these pseudo sprites. It is passed to a function which goes through the referenced ram tile ids and blits them against the background at where they are positioned. The blit can only happen once since I am changing the source data. Unlike kernel sprites which are sourced from flash and re-blitted each frame. I only need the one blit. As a result any pseudo sprite that is drawn more than once needs to be on the same background or it'll look silly. Same with x-flipping where maybe one half of the sprite is on grass and the other is on sand. Unless it is the same tile on both sides it'll look silly.

Can I use the kernel's function somehow?
User avatar
Jubatian
Posts: 1561
Joined: Thu Oct 01, 2015 9:44 pm
Location: Hungary
Contact:

Re: An RPG on Uzebox

Post by Jubatian »

BlitSprite wasn't exposed in the original Mode 3, possibly it wasn't even in a suitable format for interfacing (I think I created it removing the guts of ProcessSprites to have it, although I am not wholly sure now).

It is not available if you are using the kernel's sprite engine (the sprites array). If you set SPRITES_AUTO_PROCESS = 0, you get something entirely different: the sprites array no longer exists, no ProcessSprites(): you can draw 8x8 tiles on the display like on a canvas using BlitSprite(), which also handles RAM tile allocation as required. It accepts the same sprite indices like the sprites array would (sprites[idx].tileIndex), and the same set of flags.

It is only capable to draw from ROM sources, though, and no color replacements are available as neither was required to sustain the original functionality of Mode 3.

I checked the repo: The Sprite Effects section in the Wiki is inaccurate today. The BlitSprite function it mentions is now called BlitSpritePart (not exactly the same thing though), and what the current Mode 3 has named BlitSprite is new, something which didn't exist before (it was the guts of ProcessSprites()). For me that section seems to refer to kernel hacking, that is, modifying the ProcessSprites() function to do some extra stuff. BlitSpritePart isn't very useful as it is just for blitting a sprite fragment into a RAM tile (unaligned sprites take 4 BlitSpritePart calls in a BlitSprite to draw each of their fragments in the covered RAM tiles).
User avatar
D3thAdd3r
Posts: 3221
Joined: Wed Apr 29, 2009 10:00 am
Location: Minneapolis, United States

Re: An RPG on Uzebox

Post by D3thAdd3r »

I didn't look deep enough to realize it the first time, but I will have to remove or adjust some things and clean up some dead ends where I mention Ram Tile Library. The "high level" parts like partitioned sprites/etc. should be valid, but any low level kernel hacks suggested there should be ignored, as there are some fundamental changes made since it was written, involving anything with BlitSprite/ProcessSprites. User Ram Tiles did not exist back then, and many a crappy idea had I experimented with before I realized there is no generic answer to some of these problems.
User avatar
nicksen782
Posts: 714
Joined: Wed Feb 01, 2012 8:23 pm
Location: Detroit, United States
Contact:

Re: An RPG on Uzebox

Post by nicksen782 »

Then it seems that my manual blit method will be what I use.

These are the blitting functions. Could they be more efficient? Can you see what I am doing here? I know I haven't included the supporting structs but you should be able to see what I'm trying to do.

Code: Select all

u8 getBankFromPos(u32 pos){
	return ( (uint8_t)  ( (uint32_t) (pos) >> 16) );
}
u16 getAddrFromPos(u32 pos){
	return ( (uint16_t) ( (uint32_t) (pos)      ) ) ;
}

void blitPseudoSprites(){
	u8 getScreenTileFromSPIRAM(u8 x, u8 y){
		s32 pos       ;
		uint8_t bank  ;
		uint16_t addr ;
		u8 tileId     ;

		// Address the SPI RAM.
		pos  = screen.tilemap_offset + ( y*screen.screenWidth )+(x)+2;
		// bank = (uint8_t)  ( (uint32_t) (pos) >> 16);
		// addr = (uint16_t) ( (uint32_t) (pos)      );
		bank              = getBankFromPos(pos);
		addr              = getAddrFromPos(pos);

		// Get the data (just one byte.)
		SpiRamReadInto(bank, addr, &tileId , 1);

		// Return the tile id.
		return tileId;
	};

	void blitMap(const char *map, u8 rmap_id, u8 x, u8 y){
		u8 mapWidth  = pgm_read_byte(&(map[0])); // Map width.
		u8 mapHeight = pgm_read_byte(&(map[1])); // Map height.
		u8 thisMapTileId          ;              // Holds the ram tile id for the current ram tile.
		u8 mapArrayIndex=2        ;              // Array index used for the map. Skip first two bytes which are width and height.
		u8 vramTileId             ;              // Tile id value in vram.
		const char * vramTileData ;              // Pointer to starting tile position for this tile in the tileset.
		u8 *ramTile               ;              // Pointer to start of this ram tile.

		for(u8 dy=0;dy<mapHeight;dy++){
			for(u8 dx=0;dx<mapWidth;dx++){
				// Get vram tile id.
				// vramTileId = GetTile( x+dx, y+dy ) ;
				// vramTileId = vram[ ((y+dy)*VRAM_TILES_H)+(x+dx) ]-RAM_TILES_COUNT;
				vramTileId = getScreenTileFromSPIRAM(x+dx, y+dy);

				// Get the data for the vram tile. (PROGMEM. Will need pgm_read_byte.)
				vramTileData = &UzeRPG_A1_1_2[ (u16) ( (TILE_WIDTH*TILE_HEIGHT) * vramTileId ) ];

				// Get ram_tiles pointer (from PROGMEM map.)
				thisMapTileId = pgm_read_byte(&(map[mapArrayIndex++]));
				ramTile  = &ram_tiles[ (u16) ( (thisMapTileId) * (TILE_WIDTH*TILE_HEIGHT) ) ];

				// Go through each pixel and compare bytes.
				for(u8 thisPixel=0; thisPixel < (TILE_WIDTH*TILE_HEIGHT); thisPixel++){
					// Check each pixel for the translucent color value.
					if(ramTile[thisPixel]==TRANSLUCENT_COLOR){
						// Replace the translucent pixel in the ram tile with the vram tile pixel.
						ramTile[thisPixel] = pgm_read_byte( &(vramTileData[thisPixel]) );
					}
				}
			}
		}
	};

	u8 numUniques = sizeof(screen.unique_rtms);
	u8 numNPCs    = sizeof(screen.npc)/sizeof(screen.npc[0]);

	for(u8 i=0; i<numUniques; i++){
		// If the id is 0 then skip this iteration since the map is not valid.
		if( screen.unique_rtms[i] == 0 ) { continue; }

		// Get the tilemap, width, and height for this rtmap.
		const char * map = get_rmap_by_rmap_id( screen.unique_rtms[i] );

		// Go through visible NPCs. Look for match on rmap_id.
		for(u8 n=0; n<numNPCs; n++){
			// Skip non visible.
			if( screen.npc[n].visible==0 ) { continue; }

			// Skip rmap_id of 0.
			if( screen.npc[n].rmap_id == 0 ) { continue; }

			// If matching rmap_id.
			if( screen.npc[n].rmap_id == screen.unique_rtms[i] ){
				blitMap(map, screen.unique_rtms[i], screen.npc[n].charx, screen.npc[n].chary);
			}

			// If the rmap_id and the alt_rmap_id do NOT match...
			if( screen.npc[n].alt_rmap_id != screen.npc[n].rmap_id ) {
				// Skip alt_rmap_id of 0.
				if( screen.npc[n].alt_rmap_id == 0 ) { continue; }

				// If matching alt_rmap_id.
				if( screen.npc[n].alt_rmap_id == screen.unique_rtms[i] ){
					blitMap(map, screen.unique_rtms[i], screen.npc[n].charx, screen.npc[n].chary);
				}
			}

		}

	};
}
User avatar
D3thAdd3r
Posts: 3221
Joined: Wed Apr 29, 2009 10:00 am
Location: Minneapolis, United States

Re: An RPG on Uzebox

Post by D3thAdd3r »

I think I get the gist of it. In the map drawing loop, maybe it doesn't matter, but you should be able to shave off ~100 cycles for every tile by avoiding the SpiRamReadInto and instead setting up the read every vertical/y iteration and ending it after all x loops are done for that iteration. Nothing else jumps out to me at the moment.
Post Reply