From 8b1e70fa58a8e3475d607a259a10ea36e64c4b05 Mon Sep 17 00:00:00 2001 From: Andy McFadden Date: Sun, 1 May 2022 11:07:56 -0700 Subject: [PATCH] Expand C64 sprite visualizer Added fonts and sprite sheets. Updated documentation. --- SourceGen/RuntimeData/Commodore/VisC64.cs | 379 +++++++++++++++++----- docs/sgmanual/visualization.html | 17 +- 2 files changed, 308 insertions(+), 88 deletions(-) diff --git a/SourceGen/RuntimeData/Commodore/VisC64.cs b/SourceGen/RuntimeData/Commodore/VisC64.cs index 260d33b..fb96313 100644 --- a/SourceGen/RuntimeData/Commodore/VisC64.cs +++ b/SourceGen/RuntimeData/Commodore/VisC64.cs @@ -13,12 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +//#define SHOW_BORDER using System; using System.Collections.ObjectModel; using PluginCommon; + namespace RuntimeData.Commodore { + /// + /// Visualizer for C64 sprites and fonts. + /// + /// + /// References: + /// https://www.c64-wiki.com/wiki/Sprite + /// http://sta.c64.org/cbm64disp.html + /// http://unusedino.de/ec64/technical/misc/vic656x/colors/ + /// public class VisC64 : MarshalByRefObject, IPlugin, IPlugin_Visualizer { // IPlugin public string Identifier { @@ -30,9 +41,14 @@ namespace RuntimeData.Commodore { // Visualization identifiers; DO NOT change or projects that use them will break. private const string VIS_GEN_HI_RES_SPRITE = "c64-hi-res-sprite"; + private const string VIS_GEN_HI_RES_SPRITE_GRID = "c64-hi-res-sprite-grid"; private const string VIS_GEN_MULTI_COLOR_SPRITE = "c64-multi-color-sprite"; + private const string VIS_GEN_MULTI_COLOR_SPRITE_GRID = "c64-multi-color-sprite-grid"; + private const string VIS_GEN_HI_RES_FONT = "c64-hi-res-font"; + private const string VIS_GEN_MULTI_COLOR_FONT = "c64-multi-color-font"; private const string P_OFFSET = "offset"; + private const string P_COUNT = "count"; private const string P_DOUBLE_WIDE = "doubleWide"; private const string P_DOUBLE_HIGH = "doubleHigh"; private const string P_COLOR = "color"; // sprite color (hi-res or multi-color) @@ -40,9 +56,10 @@ namespace RuntimeData.Commodore { private const string P_COLOR_11 = "color11"; // multi-color 2 private const int MAX_COLOR = 15; - private const int BYTE_WIDTH = 3; - private const int HEIGHT = 21; - private const int SPRITE_SIZE = BYTE_WIDTH * HEIGHT; // 63 + private const int SPRITE_BYTE_WIDTH = 3; + private const int SPRITE_HEIGHT = 21; + private const int SPRITE_SIZE = SPRITE_BYTE_WIDTH * SPRITE_HEIGHT; // 63 + private const int SPRITE_STRIDE = 64; // hardware sprites are 64-byte aligned // Visualization descriptors. private VisDescr[] mDescriptors = new VisDescr[] { @@ -72,6 +89,50 @@ namespace RuntimeData.Commodore { new VisParamDescr("Double high", P_DOUBLE_HIGH, typeof(bool), 0, 0, 0, false), }), + new VisDescr(VIS_GEN_HI_RES_SPRITE_GRID, "C64 Hi-Res Sprite Sheet", VisDescr.VisType.Bitmap, + new VisParamDescr[] { + new VisParamDescr("File offset (hex)", + P_OFFSET, typeof(int), 0, 0x00ffffff, VisParamDescr.SpecialMode.Offset, 0), + new VisParamDescr("Number of items", + P_COUNT, typeof(int), 1, 1024, 0, 16), + new VisParamDescr("Sprite color", + P_COLOR, typeof(int), 0, 15, 0, 0), + new VisParamDescr("Double wide", + P_DOUBLE_WIDE, typeof(bool), 0, 0, 0, false), + new VisParamDescr("Double high", + P_DOUBLE_HIGH, typeof(bool), 0, 0, 0, false), + }), + new VisDescr(VIS_GEN_MULTI_COLOR_SPRITE_GRID, "C64 Multi-Color Sprite Sheet", VisDescr.VisType.Bitmap, + new VisParamDescr[] { + new VisParamDescr("File offset (hex)", + P_OFFSET, typeof(int), 0, 0x00ffffff, VisParamDescr.SpecialMode.Offset, 0), + new VisParamDescr("Number of items", + P_COUNT, typeof(int), 1, 1024, 0, 16), + new VisParamDescr("Sprite color", + P_COLOR, typeof(int), 0, 15, 0, 1), + new VisParamDescr("Multi-color 1", + P_COLOR_01, typeof(int), 0, 15, 0, 0), + new VisParamDescr("Multi-color 2", + P_COLOR_11, typeof(int), 0, 15, 0, 2), + new VisParamDescr("Double wide", + P_DOUBLE_WIDE, typeof(bool), 0, 0, 0, false), + new VisParamDescr("Double high", + P_DOUBLE_HIGH, typeof(bool), 0, 0, 0, false), + }), + new VisDescr(VIS_GEN_HI_RES_FONT, "C64 Hi-Res Font", VisDescr.VisType.Bitmap, + new VisParamDescr[] { + new VisParamDescr("File offset (hex)", + P_OFFSET, typeof(int), 0, 0x00ffffff, VisParamDescr.SpecialMode.Offset, 0), + new VisParamDescr("Number of items", + P_COUNT, typeof(int), 1, 256, 0, 96), + }), + new VisDescr(VIS_GEN_MULTI_COLOR_FONT, "C64 Multi-Color Font", VisDescr.VisType.Bitmap, + new VisParamDescr[] { + new VisParamDescr("File offset (hex)", + P_OFFSET, typeof(int), 0, 0x00ffffff, VisParamDescr.SpecialMode.Offset, 0), + new VisParamDescr("Number of items", + P_COUNT, typeof(int), 1, 256, 0, 96), + }), }; @@ -104,74 +165,36 @@ namespace RuntimeData.Commodore { ReadOnlyDictionary parms) { switch (descr.Ident) { case VIS_GEN_HI_RES_SPRITE: - return GenerateHiResSprite(parms); + return GenerateSprite(parms, false); case VIS_GEN_MULTI_COLOR_SPRITE: - return GenerateMultiColorSprite(parms); + return GenerateSprite(parms, true); + case VIS_GEN_HI_RES_SPRITE_GRID: + return GenerateSpriteGrid(parms, false); + case VIS_GEN_MULTI_COLOR_SPRITE_GRID: + return GenerateSpriteGrid(parms, true); + case VIS_GEN_HI_RES_FONT: + return GenerateFont(parms, false); + case VIS_GEN_MULTI_COLOR_FONT: + return GenerateFont(parms, true); default: mAppRef.ReportError("Unknown ident " + descr.Ident); return null; } } - private IVisualization2d GenerateHiResSprite(ReadOnlyDictionary parms) { + private IVisualization2d GenerateSprite(ReadOnlyDictionary parms, + bool isMultiColor) { int offset = Util.GetFromObjDict(parms, P_OFFSET, 0); byte color = (byte)Util.GetFromObjDict(parms, P_COLOR, 0); bool isDoubleWide = Util.GetFromObjDict(parms, P_DOUBLE_WIDE, false); bool isDoubleHigh = Util.GetFromObjDict(parms, P_DOUBLE_HIGH, false); - - if (offset < 0 || offset >= mFileData.Length || color < 0 || color > MAX_COLOR) { - // the UI should flag these based on range (and ideally wouldn't have called us) - mAppRef.ReportError("Invalid parameter"); - return null; + byte color01 = 0; + byte color11 = 0; + if (isMultiColor) { + color01 = (byte)Util.GetFromObjDict(parms, P_COLOR_01, 0); + color11 = (byte)Util.GetFromObjDict(parms, P_COLOR_11, 0); } - int lastOffset = offset + SPRITE_SIZE - 1; - if (lastOffset >= mFileData.Length) { - mAppRef.ReportError("Sprite runs off end of file (last offset +" + - lastOffset.ToString("x6") + ")"); - return null; - } - - int xwide = isDoubleWide ? 2 : 1; - int xhigh = isDoubleHigh ? 2 : 1; - - VisBitmap8 vb = new VisBitmap8(BYTE_WIDTH * 8 * xwide, HEIGHT * xhigh); - SetPalette(vb); - - // Clear all pixels to transparent, then just draw the non-transparent ones. - vb.SetAllPixelIndices(TRANSPARENT); - - for (int row = 0; row < HEIGHT; row++) { - for (int col = 0; col < BYTE_WIDTH; col++) { - byte val = mFileData[offset + row * BYTE_WIDTH + col]; - for (int bit = 0; bit < 8; bit++) { - if ((val & 0x80) != 0) { - int xc = (col * 8 + bit) * xwide; - int yc = row * xhigh; - vb.SetPixelIndex(xc, yc, color); - if (isDoubleWide || isDoubleHigh) { - // Draw doubled pixels. If we're only doubled in one dimension - // this will draw pixels twice. - vb.SetPixelIndex(xc + xwide - 1, yc, color); - vb.SetPixelIndex(xc, yc + xhigh - 1, color); - vb.SetPixelIndex(xc + xwide - 1, yc + xhigh - 1, color); - } - } - val <<= 1; - } - } - } - return vb; - } - - private IVisualization2d GenerateMultiColorSprite(ReadOnlyDictionary parms) { - int offset = Util.GetFromObjDict(parms, P_OFFSET, 0); - byte color = (byte)Util.GetFromObjDict(parms, P_COLOR, 0); - byte color01 = (byte)Util.GetFromObjDict(parms, P_COLOR_01, 0); - byte color11 = (byte)Util.GetFromObjDict(parms, P_COLOR_11, 0); - bool isDoubleWide = Util.GetFromObjDict(parms, P_DOUBLE_WIDE, false); - bool isDoubleHigh = Util.GetFromObjDict(parms, P_DOUBLE_HIGH, false); - if (offset < 0 || offset >= mFileData.Length || color < 0 || color > MAX_COLOR || color01 < 0 || color01 > MAX_COLOR || @@ -180,7 +203,6 @@ namespace RuntimeData.Commodore { mAppRef.ReportError("Invalid parameter"); return null; } - int lastOffset = offset + SPRITE_SIZE - 1; if (lastOffset >= mFileData.Length) { mAppRef.ReportError("Sprite runs off end of file (last offset +" + @@ -191,13 +213,196 @@ namespace RuntimeData.Commodore { int xwide = isDoubleWide ? 2 : 1; int xhigh = isDoubleHigh ? 2 : 1; - VisBitmap8 vb = new VisBitmap8(BYTE_WIDTH * 8 * xwide, HEIGHT * xhigh); + VisBitmap8 vb = new VisBitmap8(SPRITE_BYTE_WIDTH * 8 * xwide, SPRITE_HEIGHT * xhigh); SetPalette(vb); vb.SetAllPixelIndices(TRANSPARENT); - for (int row = 0; row < HEIGHT; row++) { - for (int col = 0; col < BYTE_WIDTH; col++) { - byte val = mFileData[offset + row * BYTE_WIDTH + col]; + if (isMultiColor) { + RenderMultiColorBitmap(offset, SPRITE_BYTE_WIDTH, SPRITE_HEIGHT, + isDoubleWide, isDoubleHigh, color, color01, color11, vb, 0, 0); + } else { + RenderHiResBitmap(offset, SPRITE_BYTE_WIDTH, SPRITE_HEIGHT, + isDoubleWide, isDoubleHigh, color, vb, 0, 0); + } + return vb; + } + + private IVisualization2d GenerateSpriteGrid(ReadOnlyDictionary parms, + bool isMultiColor) { + int offset = Util.GetFromObjDict(parms, P_OFFSET, 0); + int count = Util.GetFromObjDict(parms, P_COUNT, 16); + byte color = (byte)Util.GetFromObjDict(parms, P_COLOR, 0); + bool isDoubleWide = Util.GetFromObjDict(parms, P_DOUBLE_WIDE, false); + bool isDoubleHigh = Util.GetFromObjDict(parms, P_DOUBLE_HIGH, false); + byte color01 = 0; + byte color11 = 0; + if (isMultiColor) { + color01 = (byte)Util.GetFromObjDict(parms, P_COLOR_01, 0); + color11 = (byte)Util.GetFromObjDict(parms, P_COLOR_11, 0); + } + + if (offset < 0 || offset >= mFileData.Length || + color < 0 || color > MAX_COLOR || + color01 < 0 || color01 > MAX_COLOR || + color11 < 0 || color11 > MAX_COLOR) { + // the UI should flag these based on range (and ideally wouldn't have called us) + mAppRef.ReportError("Invalid parameter"); + return null; + } + int lastOffset = offset + SPRITE_STRIDE * count - 1; + if (lastOffset >= mFileData.Length) { + mAppRef.ReportError("Sprite set runs off end of file (last offset +" + + lastOffset.ToString("x6") + ")"); + return null; + } + + int xwide = isDoubleWide ? 2 : 1; + int xhigh = isDoubleHigh ? 2 : 1; + + // Try to make it square, unless there's a large number of them. Limit the width + // to 16 sprites (384 pixels + padding). + int hcells; + if (count * xwide > 64) { + hcells = 16 / xwide; + } else if (count * xwide >= 32) { + hcells = 8 / xwide; + } else { + hcells = (int)Math.Sqrt(count * xwide + 1); + } + + int vcells = (count + hcells - 1) / hcells; + + VisBitmap8 vb = new VisBitmap8(1 + hcells * SPRITE_BYTE_WIDTH * 8 * xwide + hcells, + 1 + vcells * SPRITE_HEIGHT * xhigh + vcells); + SetPalette(vb); + vb.SetAllPixelIndices(BORDER_COLOR); + + int cellX = 1; + int cellY = 1; + for (int idx = 0; idx < count; idx++) { + if (isMultiColor) { + RenderMultiColorBitmap(offset + idx * SPRITE_STRIDE, + SPRITE_BYTE_WIDTH, SPRITE_HEIGHT, isDoubleWide, isDoubleHigh, + color, color01, color11, vb, cellX, cellY); + } else { + RenderHiResBitmap(offset + idx * SPRITE_STRIDE, + SPRITE_BYTE_WIDTH, SPRITE_HEIGHT, isDoubleWide, isDoubleHigh, + color, vb, cellX, cellY); + } + + cellX += SPRITE_BYTE_WIDTH * 8 * xwide + 1; + if (cellX == vb.Width) { + cellX = 1; + cellY += SPRITE_HEIGHT * xhigh + 1; + } + } + return vb; + } + + private IVisualization2d GenerateFont(ReadOnlyDictionary parms, + bool isMultiColor) { + int offset = Util.GetFromObjDict(parms, P_OFFSET, 0); + int count = Util.GetFromObjDict(parms, P_COUNT, 96); + + if (offset < 0 || offset >= mFileData.Length) { + mAppRef.ReportError("Invalid parameter"); + return null; + } + int lastOffset = offset + count - 1; + if (lastOffset >= mFileData.Length) { + mAppRef.ReportError("Font runs off end of file (last offset +" + + lastOffset.ToString("x6") + ")"); + return null; + } + + // Set the number of horizontal cells. For small counts we try to make it square, + // for larger counts we use a reasonable power of 2. + int hcells; + if (count > 128) { + hcells = 32; + } else if (count > 64) { + hcells = 16; + } else if (count >= 32) { + hcells = 8; + } else { + hcells = (int)Math.Sqrt(count + 1); + } + + int vcells = (count + hcells - 1) / hcells; + + const int FONT_BYTE_WIDTH = 1; + const int FONT_HEIGHT = 8; + const int CELL_STRIDE = FONT_BYTE_WIDTH * FONT_HEIGHT; + + // Create a bitmap with room for each cell, plus a 1-pixel boundary + // between them and around the edges. + VisBitmap8 vb = new VisBitmap8(1 + hcells * FONT_BYTE_WIDTH * 8 + hcells, + 1 + vcells * FONT_HEIGHT + vcells); + SetPalette(vb); + vb.SetAllPixelIndices(BORDER_COLOR); + + int cellX = 1; + int cellY = 1; + byte color = 0; /* black */ + byte color01 = 11; /* dark grey */ + byte color11 = 15; /* light grey */ + for (int idx = 0; idx < count; idx++) { + if (isMultiColor) { + RenderMultiColorBitmap(offset + idx * CELL_STRIDE, FONT_BYTE_WIDTH, FONT_HEIGHT, + false, false, color, color01, color11, vb, cellX, cellY); + } else { + RenderHiResBitmap(offset + idx * CELL_STRIDE, FONT_BYTE_WIDTH, FONT_HEIGHT, + false, false, color, vb, cellX, cellY); + } + + cellX += FONT_BYTE_WIDTH * 8 + 1; + if (cellX == vb.Width) { + cellX = 1; + cellY += FONT_HEIGHT + 1; + } + } + return vb; + } + + private void RenderHiResBitmap(int offset, int byteWidth, int height, + bool isDoubleWide, bool isDoubleHigh, + byte color, VisBitmap8 vb, int startx, int starty) { + int xwide = isDoubleWide ? 2 : 1; + int xhigh = isDoubleHigh ? 2 : 1; + for (int row = 0; row < height; row++) { + for (int col = 0; col < byteWidth; col++) { + byte val = mFileData[offset + row * byteWidth + col]; + for (int bit = 0; bit < 8; bit++) { + byte pixColor; + if ((val & 0x80) == 0) { + pixColor = TRANSPARENT; + } else { + pixColor = color; + } + int xc = startx + (col * 8 + bit) * xwide; + int yc = starty + row * xhigh; + vb.SetPixelIndex(xc, yc, pixColor); + if (isDoubleWide || isDoubleHigh) { + // Draw doubled pixels. If we're only doubled in one dimension + // this will draw some pixels twice. + vb.SetPixelIndex(xc + xwide - 1, yc, pixColor); + vb.SetPixelIndex(xc, yc + xhigh - 1, pixColor); + vb.SetPixelIndex(xc + xwide - 1, yc + xhigh - 1, pixColor); + } + val <<= 1; + } + } + } + } + + private void RenderMultiColorBitmap(int offset, int byteWidth, int height, + bool isDoubleWide, bool isDoubleHigh, + byte color, byte color01, byte color11, VisBitmap8 vb, int startx, int starty) { + int xwide = isDoubleWide ? 2 : 1; + int xhigh = isDoubleHigh ? 2 : 1; + for (int row = 0; row < height; row++) { + for (int col = 0; col < byteWidth; col++) { + byte val = mFileData[offset + row * byteWidth + col]; for (int bit = 0; bit < 8; bit += 2) { byte pixColor = 0; switch (val & 0xc0) { @@ -206,13 +411,14 @@ namespace RuntimeData.Commodore { case 0x40: pixColor = color01; break; case 0xc0: pixColor = color11; break; } - int xc = (col * 8 + bit) * xwide; - int yc = row * xhigh; + int xc = startx + (col * 8 + bit) * xwide; + int yc = starty + row * xhigh; + // Set two adjacent pixels. vb.SetPixelIndex(xc, yc, pixColor); vb.SetPixelIndex(xc+1, yc, pixColor); if (isDoubleWide || isDoubleHigh) { // Draw doubled pixels. If we're only doubled in one dimension - // this will draw pixels twice. + // this will draw some pixels twice. vb.SetPixelIndex(xc + xwide*2 - 2, yc, pixColor); vb.SetPixelIndex(xc + xwide*2 - 1, yc, pixColor); vb.SetPixelIndex(xc, yc + xhigh - 1, pixColor); @@ -224,31 +430,36 @@ namespace RuntimeData.Commodore { } } } - return vb; } private const byte TRANSPARENT = 16; + private const byte BORDER_COLOR = 17; // C64 colors, from http://unusedino.de/ec64/technical/misc/vic656x/colors/ // (the ones on https://www.c64-wiki.com/wiki/Color looked wrong) private void SetPalette(VisBitmap8 vb) { - vb.AddColor(0xff, 0x00, 0x00, 0x00); // 0=black - vb.AddColor(0xff, 0xff, 0xff, 0xff); // 1=white - vb.AddColor(0xff, 0x68, 0x37, 0x2b); // 2=red - vb.AddColor(0xff, 0x70, 0xa4, 0xb2); // 3=cyan - vb.AddColor(0xff, 0x6f, 0x3d, 0x86); // 4=purple - vb.AddColor(0xff, 0x58, 0x8d, 0x43); // 5=green - vb.AddColor(0xff, 0x35, 0x28, 0x79); // 6=blue - vb.AddColor(0xff, 0xb8, 0xc7, 0x6f); // 7=yellow - vb.AddColor(0xff, 0x6f, 0x4f, 0x25); // 8=orange - vb.AddColor(0xff, 0x43, 0x39, 0x00); // 9-brown - vb.AddColor(0xff, 0x9a, 0x67, 0x59); // 10=light red - vb.AddColor(0xff, 0x44, 0x44, 0x44); // 11=dark grey - vb.AddColor(0xff, 0x6c, 0x6c, 0x6c); // 12=grey - vb.AddColor(0xff, 0x9a, 0xd2, 0x84); // 13=light green - vb.AddColor(0xff, 0x6c, 0x5e, 0xb5); // 14=light blue - vb.AddColor(0xff, 0x95, 0x95, 0x95); // 15=light grey - vb.AddColor(0, 0, 0, 0); // 16=transparent + vb.SetColor(0, 0xff, 0x00, 0x00, 0x00); // 0=black + vb.SetColor(1, 0xff, 0xff, 0xff, 0xff); // 1=white + vb.SetColor(2, 0xff, 0x68, 0x37, 0x2b); // 2=red + vb.SetColor(3, 0xff, 0x70, 0xa4, 0xb2); // 3=cyan + vb.SetColor(4, 0xff, 0x6f, 0x3d, 0x86); // 4=purple + vb.SetColor(5, 0xff, 0x58, 0x8d, 0x43); // 5=green + vb.SetColor(6, 0xff, 0x35, 0x28, 0x79); // 6=blue + vb.SetColor(7, 0xff, 0xb8, 0xc7, 0x6f); // 7=yellow + vb.SetColor(8, 0xff, 0x6f, 0x4f, 0x25); // 8=orange + vb.SetColor(9, 0xff, 0x43, 0x39, 0x00); // 9-brown + vb.SetColor(10, 0xff, 0x9a, 0x67, 0x59); // 10=light red + vb.SetColor(11, 0xff, 0x44, 0x44, 0x44); // 11=dark grey + vb.SetColor(12, 0xff, 0x6c, 0x6c, 0x6c); // 12=grey + vb.SetColor(13, 0xff, 0x9a, 0xd2, 0x84); // 13=light green + vb.SetColor(14, 0xff, 0x6c, 0x5e, 0xb5); // 14=light blue + vb.SetColor(15, 0xff, 0x95, 0x95, 0x95); // 15=light grey + vb.SetColor(16, 0, 0, 0, 0); // 16=transparent +#if SHOW_BORDER + vb.SetColor(17, 0xff, 0x00, 0xd6, 0xff); // 17=grid border +#else + vb.SetColor(17, 0, 0, 0, 0); // 17=grid border +#endif } } } diff --git a/docs/sgmanual/visualization.html b/docs/sgmanual/visualization.html index 51a69ac..8929ee7 100644 --- a/docs/sgmanual/visualization.html +++ b/docs/sgmanual/visualization.html @@ -251,16 +251,17 @@ straightforward fashion.

repeated as-is or flipped. -

Atari Arcade : Atari/VisAVG

+

Atari Arcade : Atari/VisAVG and Atari/VisDVG

-

Different versions of Atari's Analog Vector Graphics were used in -several games, notably Battlezone, Tempest, and Star Wars. The commands +

Different versions of Atari's Analog Vector Graphics and +Digital Vector Graphics were used in several games, notably Asteroids, +Battlezone, Tempest, and Star Wars. The commands drove a vector display monitor. SourceGen visualizes them as 2D wireframes, which isn't a perfect fit since they can describe points as well as lines, but works fine for annotating a disassembly.

The visualizer takes two arguments: the offset of the start of the commands to visualize, and the base address of vector RAM. The latter -is necessary to convert AVG JMP/JSR commands into offsets.

+is needed to convert vector JMP/JSR commands into offsets.

Commodore 64 : Commodore/VisC64

@@ -297,6 +298,14 @@ It comes in two basic varieties:

a sprite that is doubled in both width and height will look exactly like a sprite that is not doubled at all.

+

If multiple sprites are defined consecutively, at 64-byte intervals, +they can be combined into a sprite sheet. A single set of parameters +for color and scaling is applied to all sprites.

+ +

The visualizer also handles high-resolution and multi-color fonts, +which are displayed in a grid.

+ +

Nintendo Entertainment System : Nintendo/VisNES

NES PPU pattern tables hold 8x8 tiles with 2 bits of color per pixel.