diff --git a/sdk/SMX.h b/sdk/SMX.h index eba1ac7..7ce3898 100644 --- a/sdk/SMX.h +++ b/sdk/SMX.h @@ -68,6 +68,11 @@ SMX_API uint16_t SMX_GetInputState(int pad); // controlling lights should send light updates continually, even if the lights aren't changing. SMX_API void SMX_SetLights(const char lightsData[864]); +// This is the same as SMX_SetLights, but receives lights for each pad in separate +// buffers of 432 colors each. lightsDataSize[pad] is the size of lightsData. If +// it's not 432, that pad's lights will be left unchanged. +SMX_API void SMX_SetLights2(const char *lightsData[2], int lightsDataSize[2]); + // By default, the panels light automatically when stepped on. If a lights command is sent by // the application, this stops happening to allow the application to fully control lighting. // If no lights update is received for a few seconds, automatic lighting is reenabled by the diff --git a/sdk/Windows/SMX.cpp b/sdk/Windows/SMX.cpp index de7f29c..9a14ea9 100644 --- a/sdk/Windows/SMX.cpp +++ b/sdk/Windows/SMX.cpp @@ -60,6 +60,21 @@ SMX_API void SMX_FactoryReset(int pad) { SMXManager::g_pSMX->GetDevice(pad)->Fac SMX_API void SMX_ForceRecalibration(int pad) { SMXManager::g_pSMX->GetDevice(pad)->ForceRecalibration(); } SMX_API void SMX_SetTestMode(int pad, SensorTestMode mode) { SMXManager::g_pSMX->GetDevice(pad)->SetSensorTestMode((SensorTestMode) mode); } SMX_API bool SMX_GetTestData(int pad, SMXSensorTestModeData *data) { return SMXManager::g_pSMX->GetDevice(pad)->GetTestData(*data); } -SMX_API void SMX_SetLights(const char lightsData[864]) { SMXManager::g_pSMX->SetLights(string(lightsData, 864)); } +SMX_API void SMX_SetLights(const char lightsData[864]) +{ + string lights[] = { + string(lightsData, 432), + string(lightsData+432, 432), + }; + SMXManager::g_pSMX->SetLights(lights); +} +SMX_API void SMX_SetLights2(const char *lightsData[2], int lightsDataSize[2]) +{ + string lights[] = { + string(lightsData[0], lightsDataSize[0]), + string(lightsData[1], lightsDataSize[1]), + }; + SMXManager::g_pSMX->SetLights(lights); +} SMX_API void SMX_ReenableAutoLights() { SMXManager::g_pSMX->ReenableAutoLights(); } SMX_API const char *SMX_Version() { return SMX_BUILD_VERSION; } diff --git a/sdk/Windows/SMX.vcxproj b/sdk/Windows/SMX.vcxproj index 0b77f94..3b648ea 100644 --- a/sdk/Windows/SMX.vcxproj +++ b/sdk/Windows/SMX.vcxproj @@ -18,6 +18,7 @@ + @@ -29,6 +30,7 @@ + diff --git a/sdk/Windows/SMX.vcxproj.filters b/sdk/Windows/SMX.vcxproj.filters index a048bd5..68d2ac6 100644 --- a/sdk/Windows/SMX.vcxproj.filters +++ b/sdk/Windows/SMX.vcxproj.filters @@ -37,6 +37,9 @@ Source Files + + Source Files + @@ -66,5 +69,8 @@ Source Files + + Source Files + - \ No newline at end of file + diff --git a/sdk/Windows/SMXGif.cpp b/sdk/Windows/SMXGif.cpp new file mode 100644 index 0000000..b3b47f5 --- /dev/null +++ b/sdk/Windows/SMXGif.cpp @@ -0,0 +1,530 @@ +#include "SMXGif.h" +#include +#include +#include +using namespace std; + +// This is a simple animated GIF decoder. It always decodes to RGBA color, +// discarding palettes, and decodes the whole file at once. + +class GIFError: public exception { }; + +struct Palette +{ + SMXGif::Color color[256]; +}; + +void SMXGif::GIFImage::Init(int width_, int height_) +{ + width = width_; + height = height_; + image.resize(width * height); +} + +void SMXGif::GIFImage::Clear(const Color &color) +{ + for(int y = 0; y < height; ++y) + for(int x = 0; x < width; ++x) + get(x,y) = color; +} + +void SMXGif::GIFImage::CropImage(SMXGif::GIFImage &dst, int crop_left, int crop_top, int crop_width, int crop_height) const +{ + dst.Init(crop_width, crop_height); + + for(int y = 0; y < crop_height; ++y) + { + for(int x = 0; x < crop_width; ++x) + dst.get(x,y) = get(x + crop_left, y + crop_top); + } +} + +void SMXGif::GIFImage::Blit(SMXGif::GIFImage &src, int dst_left, int dst_top, int dst_width, int dst_height) +{ + for(int y = 0; y < dst_height; ++y) + { + for(int x = 0; x < dst_width; ++x) + get(x + dst_left, y + dst_top) = src.get(x, y); + } +} + +class DataStream +{ +public: + DataStream(const string &data_): + data(data_) + { + } + + uint8_t ReadByte() + { + if(pos >= data.size()) + throw GIFError(); + + uint8_t result = data[pos]; + pos++; + return result; + } + + uint16_t ReadLE16() + { + uint8_t byte1 = ReadByte(); + uint8_t byte2 = ReadByte(); + return byte1 | (byte2 << 8); + } + + void ReadBytes(string &s, int count) + { + s.clear(); + while(count--) + s.push_back(ReadByte()); + } + + void skip(int bytes) + { + pos += bytes; + } + +private: + const string &data; + uint32_t pos = 0; +}; + +class LWZStream +{ +public: + LWZStream(DataStream &stream_): + stream(stream_) + { + } + + // Read one LZW code from the input data. + uint32_t ReadLZWCode(uint32_t bit_count) + { + while(bits_in_buffer < bit_count) + { + if(bytes_remaining == 0) + { + // Read the next block's byte count. + bytes_remaining = stream.ReadByte(); + if(bytes_remaining == 0) + throw GIFError(); + } + + // Shift in another 8 bits into the end of self.bits. + bits |= stream.ReadByte() << bits_in_buffer; + bits_in_buffer += 8; + bytes_remaining -= 1; + } + + // Shift out bit_count worth of data from the end. + uint32_t result = bits & ((1 << bit_count) - 1); + bits >>= bit_count; + bits_in_buffer -= bit_count; + + return result; + } + + // Skip the rest of the LZW data. + void Flush() + { + stream.skip(bytes_remaining); + bytes_remaining = 0; + + // If there are any blocks past the end of data, skip them. + while(1) + { + uint8_t blocksize = stream.ReadByte(); + stream.skip(blocksize); + if(bytes_remaining == 0) + break; + } + } + +private: + DataStream &stream; + uint32_t bits = 0; + int bytes_remaining = 0; + int bits_in_buffer = 0; +}; + +struct LWZDecoder +{ + LWZDecoder(DataStream &stream): + lzw_stream(LWZStream(stream)) + { + // Each frame has a single bits field. + code_bits = stream.ReadByte(); + } + + string DecodeImage(); + +private: + uint16_t code_bits; + LWZStream lzw_stream; +}; + + +static const int GIFBITS = 12; + +string LWZDecoder::DecodeImage() +{ + uint32_t dictionary_bits = code_bits + 1; + int prev_code1 = -1; + int prev_code2 = -1; + + uint32_t clear = 1 << code_bits; + uint32_t end = clear + 1; + uint32_t next_free_slot = clear + 2; + + vector> dictionary; + dictionary.resize(1 << GIFBITS); + + // We append to this buffer as we decode data, then append the data in reverse + // order. + string append_buffer; + + string result; + while(1) + { + // Flush append_buffer. + for(int i = append_buffer.size() - 1; i >= 0; --i) + result.push_back(append_buffer[i]); + append_buffer.clear(); + + int code1 = lzw_stream.ReadLZWCode(dictionary_bits); + // printf("%02x"); + if(code1 == end) + break; + + if(code1 == clear) + { + // Clear the dictionary and reset. + dictionary_bits = code_bits + 1; + next_free_slot = clear + 2; + prev_code1 = -1; + prev_code2 = -1; + continue; + } + + int code2; + if(code1 < next_free_slot) + code2 = code1; + else if(code1 == next_free_slot && prev_code2 != -1) + { + append_buffer.push_back(prev_code2); + code2 = prev_code1; + } + else + throw GIFError(); + + // Walk through the linked list of codes in the dictionary and append. + while(code2 >= clear + 2) + { + uint8_t append_char = dictionary[code2].first; + code2 = dictionary[code2].second; + append_buffer.push_back(append_char); + } + append_buffer.push_back(code2); + + // If we're already at the last free slot, the dictionary is full and can't be expanded. + if(next_free_slot < (1 << dictionary_bits)) + { + // If we have any free dictionary slots, save. + if(prev_code1 != -1) + { + dictionary[next_free_slot] = make_pair(code2, prev_code1); + next_free_slot += 1; + } + // If we've just filled the last dictionary slot, expand the dictionary size if possible. + if(next_free_slot >= (1 << dictionary_bits) && dictionary_bits < GIFBITS) + dictionary_bits += 1; + } + + prev_code1 = code1; + prev_code2 = code2; + } + + // Skip any remaining data in this block. + lzw_stream.Flush(); + + return result; +} + +struct GlobalGIFData +{ + int width = 0, height = 0; + int background_index = -1; + bool use_transparency = false; + int transparency_index = -1; + int duration = 0; + int disposal_method = 0; + bool have_global_palette = false; + Palette palette; +}; + +class GIFDecoder +{ +public: + GIFDecoder(DataStream &stream_): + stream(stream_) + { + } + + void ReadAllFrames(vector &frames); + +private: + bool ReadPacket(string &packet); + Palette ReadPalette(int palette_size); + void DecodeImage(GlobalGIFData global_data, SMXGif::GIFImage &out); + + DataStream &stream; + SMXGif::GIFImage image; + int frame; +}; + +// Read a palette with size colors. +// +// This is a simple string, with 4 RGBA bytes per color. +Palette GIFDecoder::ReadPalette(int palette_size) +{ + Palette result; + for(int i = 0; i < palette_size; ++i) + { + result.color[i].color[0] = stream.ReadByte(); // R + result.color[i].color[1] = stream.ReadByte(); // G + result.color[i].color[2] = stream.ReadByte(); // B + result.color[i].color[3] = 0xFF; + } + return result; +} + +bool GIFDecoder::ReadPacket(string &packet) +{ + uint8_t packet_size = stream.ReadByte(); + if(packet_size == 0) + return false; + + stream.ReadBytes(packet, packet_size); + return true; +} + +void GIFDecoder::ReadAllFrames(vector &frames) +{ + string header; + stream.ReadBytes(header, 6); + + if(header != "GIF87a" && header != "GIF89a") + throw GIFError(); + + GlobalGIFData global_data; + + global_data.width = stream.ReadLE16(); + global_data.height = stream.ReadLE16(); + image.Init(global_data.width, global_data.height); + + // Ignore the aspect ratio field. (Supporting pixel aspect ratios in a format + // this rudimentary was almost ambitious of them...) + uint8_t global_flags = stream.ReadByte(); + global_data.background_index = stream.ReadByte(); + + // Ignore the aspect ratio field. (Supporting pixel aspect ratios in a format + // this rudimentary was almost ambitious of them...) + stream.ReadByte(); + + // Decode global_flags. + uint8_t global_palette_size = global_flags & 0x7; + + global_data.have_global_palette = (global_flags >> 7) & 0x1; + + + // If there's no global palette, leave it empty. + if(global_data.have_global_palette) + global_data.palette = ReadPalette(1 << (global_palette_size + 1)); + + frame = 0; + + // Save a copy of global data, so we can restore it after each frame. + GlobalGIFData saved_global_data = global_data; + + // Decode all packets. + while(1) + { + uint8_t packet_type = stream.ReadByte(); + if(packet_type == 0x21) + { + // Extension packet + uint8_t extension_type = stream.ReadByte(); + + if(extension_type == 0xF9) + { + string packet; + if(!ReadPacket(packet)) + throw GIFError(); + + DataStream packet_buf(packet); + + // Graphics control extension + uint8_t gce_flags = packet_buf.ReadByte(); + global_data.duration = packet_buf.ReadLE16(); + global_data.transparency_index = packet_buf.ReadByte(); + + global_data.use_transparency = bool(gce_flags & 1); + global_data.disposal_method = (gce_flags >> 2) & 0xF; + if(!global_data.use_transparency) + global_data.transparency_index = -1; + } + + // Read any remaining packets in this extension packet. + while(1) + { + string packet; + if(!ReadPacket(packet)) + break; + } + } + else if(packet_type == 0x2C) + { + // Image data + SMXGif::GIFImage frame_image; + DecodeImage(global_data, frame_image); + + SMXGif::SMXGifFrame gif_frame; + gif_frame.width = global_data.width; + gif_frame.height = global_data.height; + gif_frame.milliseconds = global_data.duration * 10; + gif_frame.frame = frame_image; + frames.push_back(gif_frame); + + frame++; + + // Reset GCE (frame-specific) data. + global_data = saved_global_data; + } + else if(packet_type == 0x3B) + { + // EOF + return; + } + else + throw GIFError(); + } +} + +// Decode a single GIF image into out, leaving this->image ready for +// the next frame (with this frame's dispose applied). +void GIFDecoder::DecodeImage(GlobalGIFData global_data, SMXGif::GIFImage &out) +{ + uint16_t block_left = stream.ReadLE16(); + uint16_t block_top = stream.ReadLE16(); + uint16_t block_width = stream.ReadLE16(); + uint16_t block_height = stream.ReadLE16(); + uint8_t local_flags = stream.ReadByte(); + + // area = (block_left, block_top, block_left + block_width, block_top + block_height) + // Extract flags: + uint8_t have_local_palette = (local_flags >> 7) & 1; + // bool interlaced = (local_flags >> 6) & 1; + uint8_t local_palette_size = (local_flags >> 0) & 0x7; + // print 'Interlaced:', interlaced + + // We don't support interlaced GIFs right now. + // assert interlaced == 0 + + // If this frame has a local palette, use it. Otherwise, use the global palette. + Palette active_palette = global_data.palette; + if(have_local_palette) + active_palette = ReadPalette(1 << (local_palette_size + 1)); + + if(!global_data.have_global_palette && !have_local_palette) + { + // We have no palette. This is an invalid file. + throw GIFError(); + } + + if(frame == 0) + { + // On the first frame, clear the buffer. If we have a transparency index, + // clear to transparent. Otherwise, clear to the background color. + if(global_data.transparency_index != -1) + image.Clear(SMXGif::Color(0,0,0,0)); + else + image.Clear(active_palette.color[global_data.background_index]); + } + + // Decode the compressed image data. + LWZDecoder decoder(stream); + string decompressed_data = decoder.DecodeImage(); + + if(decompressed_data.size() < block_width*block_height) + throw GIFError(); + + // Save the region to restore after decoding. + SMXGif::GIFImage dispose; + if(global_data.disposal_method <= 1) + { + // No disposal. + } + else if(global_data.disposal_method == 2) + { + // Clear the region to a background color afterwards. + dispose.Init(block_width, block_height); + + if(global_data.transparency_index != -1) + dispose.Clear(SMXGif::Color(0,0,0,0)); + else + { + uint8_t palette_idx = global_data.background_index; + dispose.Clear(active_palette.color[palette_idx]); + } + + } + else if(global_data.disposal_method == 3) + { + // Restore the previous frame afterwards. + image.CropImage(dispose, block_left, block_top, block_width, block_height); + } + else + { + // Unknown disposal method + } + + int pos = 0; + for(int y = block_top; y < block_top + block_height; ++y) + { + for(int x = block_left; x < block_left + block_width; ++x) + { + uint8_t palette_idx = decompressed_data[pos]; + pos++; + + if(palette_idx == global_data.transparency_index) + { + // If this pixel is transparent, leave the existing color in place. + } + else + { + image.get(x,y) = active_palette.color[palette_idx]; + } + } + } + + // Copy the image before we run dispose. + out = image; + + // Restore the dispose area. + if(dispose.width != 0) + image.Blit(dispose, block_left, block_top, block_width, block_height); +} + +bool SMXGif::DecodeGIF(string buf, vector &frames) +{ + DataStream stream(buf); + GIFDecoder gif(stream); + try { + gif.ReadAllFrames(frames); + } catch(GIFError &) { + // We don't return error strings for this, just success or failure. + return false; + } + return true; +} diff --git a/sdk/Windows/SMXGif.h b/sdk/Windows/SMXGif.h new file mode 100644 index 0000000..e02f27c --- /dev/null +++ b/sdk/Windows/SMXGif.h @@ -0,0 +1,70 @@ +#ifndef SMXGif_h +#define SMXGif_h + +#include +#include +#include + +// This is a simple internal GIF decoder. It's only meant to be used by +// SMXConfig. +namespace SMXGif +{ + struct Color + { + uint8_t color[4]; + Color() + { + memset(color, 0, sizeof(color)); + } + + Color(uint8_t r, uint8_t g, uint8_t b, uint8_t a) + { + color[0] = r; + color[1] = g; + color[2] = b; + color[3] = a; + } + bool operator==(const Color &rhs) const + { + return !memcmp(color, rhs.color, sizeof(*color)); + } + }; + + struct GIFImage + { + int width = 0, height = 0; + void Init(int width, int height); + + Color get(int x, int y) const { return image[y*width+x]; } + Color &get(int x, int y) { return image[y*width+x]; } + + // Clear to a solid color. + void Clear(const Color &color); + + // Copy a rectangle from this image into dst. + void CropImage(GIFImage &dst, int crop_left, int crop_top, int crop_width, int crop_height) const; + + // Copy src into a rectangle in this image. + void Blit(GIFImage &src, int dst_left, int dst_top, int dst_width, int dst_height); + + private: + std::vector image; + }; + + struct SMXGifFrame + { + int width = 0, height = 0; + + // GIF images have a delay in 10ms units. We use 1ms for clarity. + int milliseconds = 0; + + GIFImage frame; + }; + + // Decode a GIF into a list of frames. + bool DecodeGIF(std::string buf, std::vector &frames); +} + +void gif_test(); + +#endif diff --git a/sdk/Windows/SMXManager.cpp b/sdk/Windows/SMXManager.cpp index 90771de..3473474 100644 --- a/sdk/Windows/SMXManager.cpp +++ b/sdk/Windows/SMXManager.cpp @@ -241,24 +241,11 @@ void SMX::SMXManager::ThreadMain() // that we don't send the second lights commands, since that may re-disable auto lights. // - If we have two pads, the lights update is for both pads and we'll send both commands // for both pads at the same time, so both pads update lights simultaneously. -void SMX::SMXManager::SetLights(const string &sLightData) +void SMX::SMXManager::SetLights(const string sPanelLights[2]) { g_Lock.AssertNotLockedByCurrentThread(); LockMutex L(g_Lock); - // Sanity check the lights data. It should have 18*16*3 bytes of data: RGB for each of 4x4 - // LEDs on 18 panels. - if(sLightData.size() != 2*3*3*16*3) - { - Log(ssprintf("SetLights: Lights data should be %i bytes, received %i", 2*3*3*16*3, sLightData.size())); - return; - } - - // Split the lights data into P1 and P2. - string sPanelLights[2]; - sPanelLights[0] = sLightData.substr(0, 9*16*3); - sPanelLights[1] = sLightData.substr(9*16*3); - // Separate top and bottom lights commands. // // sPanelLights[iPad] is @@ -281,42 +268,44 @@ void SMX::SMXManager::SetLights(const string &sLightData) // Set sLightsCommand[iPad][0] to include 0123 4567, and [1] to 89AB CDEF. string sLightCommands[2][2]; // sLightCommands[command][pad] - auto addByte = [&sLightCommands](int iPanel, int iByte, uint8_t iColor) { - // If iPanel is 0-8, this is for pad 0. For 9-17, it's for pad 1. - // If the color byte within the panel is in the top half, it's the first - // command, otherwise it's the second command. - int iPad = iPanel < 9? 0:1; - int iCommandIndex = iByte < 4*2*3? 0:1; - sLightCommands[iCommandIndex][iPad].append(1, iColor); - }; - // Read the linearly arranged color data we've been given and split it into top and // bottom commands for each pad. - int iNextInputByte = 0; - for(int iPanel = 0; iPanel < 18; ++iPanel) + for(int iPad = 0; iPad < 2; ++iPad) { - for(int iByte = 0; iByte < 4*4*3; ++iByte) + // If there's no data for this pad, leave the command empty. + const string &sLightsDataForPad = sPanelLights[iPad]; + if(sLightsDataForPad.empty()) + continue; + + // Sanity check the lights data. It should have 9*4*4*3 bytes of data: RGB for each of 4x4 + // LEDs on 9 panels. + if(sPanelLights[iPad].size() != 9*16*3) { - uint8_t iColor = sLightData[iNextInputByte++]; - addByte(iPanel, iByte, iColor); + Log(ssprintf("SetLights: Lights data should be %i bytes, received %i", 3*3*16*3, sPanelLights[iPad].size())); + continue; } - } - for(int iPad = 0; iPad < 2; ++iPad) - { - for(int iCommand = 0; iCommand < 2; ++iCommand) + // The 2 command sends the top 4x2 lights, and 3 sends the bottom 4x2. + sLightCommands[0][iPad] = "2"; + sLightCommands[1][iPad] = "3"; + int iNextInputByte = 0; + for(int iPanel = 0; iPanel < 9; ++iPanel) { - string &sCommand = sLightCommands[iCommand][iPad]; - - // Apply color scaling. Values over about 170 don't make the LEDs any brighter, so this - // gives better contrast and draws less power. - for(char &c: sCommand) - c = char(uint8_t(c) * 0.6666f); - - // Add the command byte. - sCommand.insert(sCommand.begin(), 1, iCommand == 0? '2':'3'); - sCommand.push_back('\n'); + for(int iByte = 0; iByte < 4*4*3; ++iByte) + { + uint8_t iColor = sLightsDataForPad[iNextInputByte++]; + + // Apply color scaling. Values over about 170 don't make the LEDs any brighter, so this + // gives better contrast and draws less power. + iColor = uint8_t(iColor * 0.6666f); + + int iCommandIndex = iByte < 4*2*3? 0:1; + sLightCommands[iCommandIndex][iPad].append(1, iColor); + } } + + sLightCommands[0][iPad].push_back('\n'); + sLightCommands[1][iPad].push_back('\n'); } // Each update adds two entries to m_aPendingCommands, one for the top half and one @@ -356,13 +345,18 @@ void SMX::SMXManager::SetLights(const string &sLightData) // Set the pad commands. PendingCommand *pPendingCommands[2]; - pPendingCommands[0] = &m_aPendingCommands[m_aPendingCommands.size()-2]; - pPendingCommands[1] = &m_aPendingCommands[m_aPendingCommands.size()-1]; + pPendingCommands[0] = &m_aPendingCommands[m_aPendingCommands.size()-2]; // 2 + pPendingCommands[1] = &m_aPendingCommands[m_aPendingCommands.size()-1]; // 3 - pPendingCommands[0]->sPadCommand[0] = sLightCommands[0][0]; - pPendingCommands[0]->sPadCommand[1] = sLightCommands[0][1]; - pPendingCommands[1]->sPadCommand[0] = sLightCommands[1][0]; - pPendingCommands[1]->sPadCommand[1] = sLightCommands[1][1]; + for(int iPad = 0; iPad < 2; ++iPad) + { + // If the command for this pad is empty, leave any existing pad command alone. + if(sLightCommands[0][iPad].empty()) + continue; + + pPendingCommands[0]->sPadCommand[iPad] = sLightCommands[0][iPad]; + pPendingCommands[1]->sPadCommand[iPad] = sLightCommands[1][iPad]; + } } void SMX::SMXManager::ReenableAutoLights() @@ -395,7 +389,10 @@ void SMX::SMXManager::SendLightUpdates() // Send the lights command for each pad. If either pad isn't connected, this won't do // anything. for(int iPad = 0; iPad < 2; ++iPad) - m_pDevices[iPad]->SendCommandLocked(command.sPadCommand[iPad]); + { + if(!command.sPadCommand[iPad].empty()) + m_pDevices[iPad]->SendCommandLocked(command.sPadCommand[iPad]); + } // Remove the command we've sent. m_aPendingCommands.erase(m_aPendingCommands.begin(), m_aPendingCommands.begin()+1); diff --git a/sdk/Windows/SMXManager.h b/sdk/Windows/SMXManager.h index 133977f..1c061f3 100644 --- a/sdk/Windows/SMXManager.h +++ b/sdk/Windows/SMXManager.h @@ -43,7 +43,7 @@ public: void Shutdown(); shared_ptr GetDevice(int pad); - void SetLights(const string &sLightData); + void SetLights(const string sLights[2]); void ReenableAutoLights(); private: