Add SMX_SetLights2.

This is the same as SMX_SetLights, but instead of taking one buffer with a
fixed size, it takes a separate buffer for each pad, and explicitly includes
the size of the buffer rather than assuming it's 864 bytes.  SMX_SetLights
and SMX_SetLights2 call into the same underlying update.
master
Glenn Maynard 6 years ago
parent d576545266
commit 303283624a
  1. 5
      sdk/SMX.h
  2. 17
      sdk/Windows/SMX.cpp
  3. 2
      sdk/Windows/SMX.vcxproj
  4. 6
      sdk/Windows/SMX.vcxproj.filters
  5. 530
      sdk/Windows/SMXGif.cpp
  6. 70
      sdk/Windows/SMXGif.h
  7. 91
      sdk/Windows/SMXManager.cpp
  8. 2
      sdk/Windows/SMXManager.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

@ -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; }

@ -18,6 +18,7 @@
<ClInclude Include="SMXDeviceConnection.h" />
<ClInclude Include="SMXDeviceSearch.h" />
<ClInclude Include="SMXDeviceSearchThreaded.h" />
<ClInclude Include="SMXGif.h" />
<ClInclude Include="SMXHelperThread.h" />
<ClInclude Include="SMXManager.h" />
<ClInclude Include="SMXThread.h" />
@ -29,6 +30,7 @@
<ClCompile Include="SMXDeviceConnection.cpp" />
<ClCompile Include="SMXDeviceSearch.cpp" />
<ClCompile Include="SMXDeviceSearchThreaded.cpp" />
<ClCompile Include="SMXGif.cpp" />
<ClCompile Include="SMXHelperThread.cpp" />
<ClCompile Include="SMXManager.cpp" />
<ClCompile Include="SMXThread.cpp" />

@ -37,6 +37,9 @@
<ClInclude Include="SMXThread.h">
<Filter>Source Files</Filter>
</ClInclude>
<ClInclude Include="SMXGif.h">
<Filter>Source Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ClCompile Include="SMX.cpp">
@ -66,5 +69,8 @@
<ClCompile Include="SMXThread.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="SMXGif.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>

@ -0,0 +1,530 @@
#include "SMXGif.h"
#include <stdint.h>
#include <string>
#include <vector>
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<pair<int,int>> 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<SMXGif::SMXGifFrame> &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<SMXGif::SMXGifFrame> &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<SMXGif::SMXGifFrame> &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;
}

@ -0,0 +1,70 @@
#ifndef SMXGif_h
#define SMXGif_h
#include <stdint.h>
#include <string>
#include <vector>
// 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<Color> 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<SMXGifFrame> &frames);
}
void gif_test();
#endif

@ -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];
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.
for(char &c: sCommand)
c = char(uint8_t(c) * 0.6666f);
// 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);
// Add the command byte.
sCommand.insert(sCommand.begin(), 1, iCommand == 0? '2':'3');
sCommand.push_back('\n');
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
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[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];
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);

@ -43,7 +43,7 @@ public:
void Shutdown();
shared_ptr<SMXDevice> GetDevice(int pad);
void SetLights(const string &sLightData);
void SetLights(const string sLights[2]);
void ReenableAutoLights();
private:

Loading…
Cancel
Save