/* * Copyright (c) Contributors, http://opensimulator.org/ * See CONTRIBUTORS.TXT for a full list of copyright holders. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of the OpenSimulator Project nor the * names of its contributors may be used to endorse or promote products * derived from this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE DEVELOPERS ``AS IS'' AND ANY * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE * DISCLAIMED. IN NO EVENT SHALL THE CONTRIBUTORS BE LIABLE FOR ANY * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ using OpenMetaverse; using System; using System.Collections.Generic; using System.Globalization; namespace OpenSim.Framework { public static class SLUtil { // private static readonly ILog m_log = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType); /// /// Asset types used only in OpenSim. /// To avoid clashing with the code numbers used in Second Life, use only negative numbers here. /// public enum OpenSimAssetType : sbyte { Material = -2 } #region SL / file extension / content-type conversions /// /// Returns the Enum entry corresponding to the given code, regardless of whether it belongs /// to the AssetType or OpenSimAssetType enums. /// public static object AssetTypeFromCode(sbyte assetType) { if (Enum.IsDefined(typeof(OpenMetaverse.AssetType), assetType)) return (OpenMetaverse.AssetType)assetType; else if (Enum.IsDefined(typeof(OpenSimAssetType), assetType)) return (OpenSimAssetType)assetType; else return OpenMetaverse.AssetType.Unknown; } private class TypeMapping { private sbyte assetType; private sbyte inventoryType; private string contentType; private string contentType2; private string extension; public sbyte AssetTypeCode { get { return assetType; } } public object AssetType { get { return AssetTypeFromCode(assetType); } } public sbyte InventoryType { get { return inventoryType; } } public string ContentType { get { return contentType; } } public string ContentType2 { get { return contentType2; } } public string Extension { get { return extension; } } private TypeMapping(sbyte assetType, sbyte inventoryType, string contentType, string contentType2, string extension) { this.assetType = assetType; this.inventoryType = inventoryType; this.contentType = contentType; this.contentType2 = contentType2; this.extension = extension; } public TypeMapping(AssetType assetType, sbyte inventoryType, string contentType, string contentType2, string extension) : this((sbyte)assetType, inventoryType, contentType, contentType2, extension) { } public TypeMapping(AssetType assetType, InventoryType inventoryType, string contentType, string contentType2, string extension) : this((sbyte)assetType, (sbyte)inventoryType, contentType, contentType2, extension) { } public TypeMapping(AssetType assetType, InventoryType inventoryType, string contentType, string extension) : this((sbyte)assetType, (sbyte)inventoryType, contentType, null, extension) { } public TypeMapping(AssetType assetType, FolderType inventoryType, string contentType, string extension) : this((sbyte)assetType, (sbyte)inventoryType, contentType, null, extension) { } public TypeMapping(OpenSimAssetType assetType, InventoryType inventoryType, string contentType, string extension) : this((sbyte)assetType, (sbyte)inventoryType, contentType, null, extension) { } } /// /// Maps between AssetType, InventoryType and Content-Type. /// Where more than one possibility exists, the first one takes precedence. E.g.: /// AssetType "AssetType.Texture" -> Content-Type "image-xj2c" /// Content-Type "image/x-j2c" -> InventoryType "InventoryType.Texture" /// private static TypeMapping[] MAPPINGS = new TypeMapping[] { new TypeMapping(AssetType.Unknown, InventoryType.Unknown, "application/octet-stream", "bin"), new TypeMapping(AssetType.Texture, InventoryType.Texture, "image/x-j2c", "image/jp2", "j2c"), new TypeMapping(AssetType.Texture, InventoryType.Snapshot, "image/x-j2c", "image/jp2", "j2c"), new TypeMapping(AssetType.TextureTGA, InventoryType.Texture, "image/tga", "tga"), new TypeMapping(AssetType.ImageTGA, InventoryType.Texture, "image/tga", "tga"), new TypeMapping(AssetType.ImageJPEG, InventoryType.Texture, "image/jpeg", "jpg"), new TypeMapping(AssetType.Sound, InventoryType.Sound, "audio/ogg", "application/ogg", "ogg"), new TypeMapping(AssetType.SoundWAV, InventoryType.Sound, "audio/x-wav", "wav"), new TypeMapping(AssetType.CallingCard, InventoryType.CallingCard, "application/vnd.ll.callingcard", "application/x-metaverse-callingcard", "callingcard"), new TypeMapping(AssetType.Landmark, InventoryType.Landmark, "application/vnd.ll.landmark", "application/x-metaverse-landmark", "landmark"), new TypeMapping(AssetType.Clothing, InventoryType.Wearable, "application/vnd.ll.clothing", "application/x-metaverse-clothing", "clothing"), new TypeMapping(AssetType.Object, InventoryType.Object, "application/vnd.ll.primitive", "application/x-metaverse-primitive", "primitive"), new TypeMapping(AssetType.Object, InventoryType.Attachment, "application/vnd.ll.primitive", "application/x-metaverse-primitive", "primitive"), new TypeMapping(AssetType.Notecard, InventoryType.Notecard, "application/vnd.ll.notecard", "application/x-metaverse-notecard", "notecard"), new TypeMapping(AssetType.LSLText, InventoryType.LSL, "application/vnd.ll.lsltext", "application/x-metaverse-lsl", "lsl"), new TypeMapping(AssetType.LSLBytecode, InventoryType.LSL, "application/vnd.ll.lslbyte", "application/x-metaverse-lso", "lso"), new TypeMapping(AssetType.Bodypart, InventoryType.Wearable, "application/vnd.ll.bodypart", "application/x-metaverse-bodypart", "bodypart"), new TypeMapping(AssetType.Animation, InventoryType.Animation, "application/vnd.ll.animation", "application/x-metaverse-animation", "animation"), new TypeMapping(AssetType.Gesture, InventoryType.Gesture, "application/vnd.ll.gesture", "application/x-metaverse-gesture", "gesture"), new TypeMapping(AssetType.Simstate, InventoryType.Snapshot, "application/x-metaverse-simstate", "simstate"), new TypeMapping(AssetType.Link, InventoryType.Unknown, "application/vnd.ll.link", "link"), new TypeMapping(AssetType.LinkFolder, InventoryType.Unknown, "application/vnd.ll.linkfolder", "linkfolder"), new TypeMapping(AssetType.Mesh, InventoryType.Mesh, "application/vnd.ll.mesh", "llm"), // The next few items are about inventory folders new TypeMapping(AssetType.Folder, FolderType.None, "application/vnd.ll.folder", "folder"), new TypeMapping(AssetType.Folder, FolderType.Root, "application/vnd.ll.rootfolder", "rootfolder"), new TypeMapping(AssetType.Folder, FolderType.Trash, "application/vnd.ll.trashfolder", "trashfolder"), new TypeMapping(AssetType.Folder, FolderType.Snapshot, "application/vnd.ll.snapshotfolder", "snapshotfolder"), new TypeMapping(AssetType.Folder, FolderType.LostAndFound, "application/vnd.ll.lostandfoundfolder", "lostandfoundfolder"), new TypeMapping(AssetType.Folder, FolderType.Favorites, "application/vnd.ll.favoritefolder", "favoritefolder"), new TypeMapping(AssetType.Folder, FolderType.CurrentOutfit, "application/vnd.ll.currentoutfitfolder", "currentoutfitfolder"), new TypeMapping(AssetType.Folder, FolderType.Outfit, "application/vnd.ll.outfitfolder", "outfitfolder"), new TypeMapping(AssetType.Folder, FolderType.MyOutfits, "application/vnd.ll.myoutfitsfolder", "myoutfitsfolder"), // This next mappping is an asset to inventory item mapping. // Note: LL stores folders as assets of type Folder = 8, and it has a corresponding InventoryType = 8 // OpenSim doesn't store folders as assets, so this mapping should only be used when parsing things from the viewer to the server new TypeMapping(AssetType.Folder, InventoryType.Folder, "application/vnd.ll.folder", "folder"), // OpenSim specific new TypeMapping(OpenSimAssetType.Material, InventoryType.Unknown, "application/llsd+xml", "material") }; private static Dictionary asset2Content; private static Dictionary asset2Extension; private static Dictionary inventory2Content; private static Dictionary content2Asset; private static Dictionary content2Inventory; private static Dictionary name2Asset = new Dictionary() { {"texture", AssetType.Texture }, {"sound", AssetType.Sound}, {"callcard", AssetType.CallingCard}, {"landmark", AssetType.Landmark}, {"script", (AssetType)4}, {"clothing", AssetType.Clothing}, {"object", AssetType.Object}, {"notecard", AssetType.Notecard}, {"category", AssetType.Folder}, {"lsltext", AssetType.LSLText}, {"lslbyte", AssetType.LSLBytecode}, {"txtr_tga", AssetType.TextureTGA}, {"bodypart", AssetType.Bodypart}, {"snd_wav", AssetType.SoundWAV}, {"img_tga", AssetType.ImageTGA}, {"jpeg", AssetType.ImageJPEG}, {"animatn", AssetType.Animation}, {"gesture", AssetType.Gesture}, {"simstate", AssetType.Simstate}, {"mesh", AssetType.Mesh}, {"settings", AssetType.Settings} }; private static Dictionary name2Inventory = new Dictionary() { {"texture", FolderType.Texture}, {"sound", FolderType.Sound}, {"callcard", FolderType.CallingCard}, {"landmark", FolderType.Landmark}, {"script", (FolderType)4}, {"clothing", FolderType.Clothing}, {"object", FolderType.Object}, {"notecard", FolderType.Notecard}, {"root", FolderType.Root}, {"lsltext", FolderType.LSLText}, {"bodypart", FolderType.BodyPart}, {"trash", FolderType.Trash}, {"snapshot", FolderType.Snapshot}, {"lostandfound", FolderType.LostAndFound}, {"animatn", FolderType.Animation}, {"gesture", FolderType.Gesture}, {"favorites", FolderType.Favorites}, {"currentoutfit", FolderType.CurrentOutfit}, {"outfit", FolderType.Outfit}, {"myoutfits", FolderType.MyOutfits}, {"mesh", FolderType.Mesh}, {"settings", FolderType.Settings}, {"suitcase", FolderType.Suitcase} }; static SLUtil() { asset2Content = new Dictionary(); asset2Extension = new Dictionary(); inventory2Content = new Dictionary(); content2Asset = new Dictionary(); content2Inventory = new Dictionary(); foreach (TypeMapping mapping in MAPPINGS) { sbyte assetType = mapping.AssetTypeCode; if (!asset2Content.ContainsKey(assetType)) asset2Content.Add(assetType, mapping.ContentType); if (!asset2Extension.ContainsKey(assetType)) asset2Extension.Add(assetType, mapping.Extension); if (!inventory2Content.ContainsKey(mapping.InventoryType)) inventory2Content.Add(mapping.InventoryType, mapping.ContentType); if (!content2Asset.ContainsKey(mapping.ContentType)) content2Asset.Add(mapping.ContentType, assetType); if (!content2Inventory.ContainsKey(mapping.ContentType)) content2Inventory.Add(mapping.ContentType, mapping.InventoryType); if (mapping.ContentType2 != null) { if (!content2Asset.ContainsKey(mapping.ContentType2)) content2Asset.Add(mapping.ContentType2, assetType); if (!content2Inventory.ContainsKey(mapping.ContentType2)) content2Inventory.Add(mapping.ContentType2, mapping.InventoryType); } } } public static AssetType SLAssetName2Type(string name) { if (name2Asset.TryGetValue(name, out AssetType type)) return type; return AssetType.Unknown; } public static FolderType SLInvName2Type(string name) { if (name2Inventory.TryGetValue(name, out FolderType type)) return type; return FolderType.None; } public static string SLAssetTypeToContentType(int assetType) { string contentType; if (!asset2Content.TryGetValue((sbyte)assetType, out contentType)) contentType = asset2Content[(sbyte)AssetType.Unknown]; return contentType; } public static string SLInvTypeToContentType(int invType) { string contentType; if (!inventory2Content.TryGetValue((sbyte)invType, out contentType)) contentType = inventory2Content[(sbyte)InventoryType.Unknown]; return contentType; } public static sbyte ContentTypeToSLAssetType(string contentType) { sbyte assetType; if (!content2Asset.TryGetValue(contentType, out assetType)) assetType = (sbyte)AssetType.Unknown; return (sbyte)assetType; } public static sbyte ContentTypeToSLInvType(string contentType) { sbyte invType; if (!content2Inventory.TryGetValue(contentType, out invType)) invType = (sbyte)InventoryType.Unknown; return (sbyte)invType; } public static string SLAssetTypeToExtension(int assetType) { string extension; if (!asset2Extension.TryGetValue((sbyte)assetType, out extension)) extension = asset2Extension[(sbyte)AssetType.Unknown]; return extension; } #endregion SL / file extension / content-type conversions static char[] seps = new char[] { '\t', '\n' }; static char[] stringseps = new char[] { '|', '\n' }; static byte[] moronize = new byte[16] { 60, 17, 94, 81, 4, 244, 82, 60, 159, 166, 152, 175, 241, 3, 71, 48 }; static int getField(string note, int start, string name, bool isString, out string value) { value = String.Empty; int end = -1; int limit = note.Length - start; if (limit > 64) limit = 64; int indx = note.IndexOf(name, start, limit); if (indx < 0) return -1; indx += name.Length + 1; // eat \t limit = note.Length - indx - 2; if (limit > 129) limit = 129; if (isString) end = note.IndexOfAny(stringseps, indx, limit); else end = note.IndexOfAny(seps, indx, limit); if (end < 0) return -1; value = note.Substring(indx, end - indx); return end; } private static UUID deMoronize(UUID id) { byte[] data = new byte[16]; id.ToBytes(data,0); for(int i = 0; i < 16; ++i) data[i] ^= moronize[i]; return new UUID(data,0); } public static InventoryItemBase GetEmbeddedItem(byte[] data, UUID itemID) { if(data == null || data.Length < 300) return null; string note = Util.UTF8.GetString(data); if (String.IsNullOrWhiteSpace(note)) return null; // waste some time checking rigid versions string version = note.Substring(0,21); if (!version.Equals("Linden text version 2")) return null; version = note.Substring(24, 25); if (!version.Equals("LLEmbeddedItems version 1")) return null; int indx = note.IndexOf(itemID.ToString(), 100); if (indx < 0) return null; indx = note.IndexOf("permissions", indx, 100); // skip parentID if (indx < 0) return null; string valuestr; indx = getField(note, indx, "base_mask", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint basemask)) return null; indx = getField(note, indx, "owner_mask", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint ownermask)) return null; indx = getField(note, indx, "group_mask", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint groupmask)) return null; indx = getField(note, indx, "everyone_mask", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint everyonemask)) return null; indx = getField(note, indx, "next_owner_mask", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint nextownermask)) return null; indx = getField(note, indx, "creator_id", false, out valuestr); if (indx < 0) return null; if (!UUID.TryParse(valuestr, out UUID creatorID)) return null; indx = getField(note, indx, "owner_id", false, out valuestr); if (indx < 0) return null; if (!UUID.TryParse(valuestr, out UUID ownerID)) return null; int limit = note.Length - indx; if (limit > 120) limit = 120; indx = note.IndexOf('}', indx, limit); // last owner if (indx < 0) return null; int curindx = indx; UUID assetID = UUID.Zero; indx = getField(note, indx, "asset_id", false, out valuestr); if (indx < 0) { indx = getField(note, curindx, "shadow_id", false, out valuestr); if (indx < 0) return null; if (!UUID.TryParse(valuestr, out assetID)) return null; assetID = deMoronize(assetID); } else { if (!UUID.TryParse(valuestr, out assetID)) return null; } indx = getField(note, indx, "type", false, out valuestr); if (indx < 0) return null; AssetType assetType = SLAssetName2Type(valuestr); indx = getField(note, indx, "inv_type", false, out valuestr); if (indx < 0) return null; FolderType invType = SLInvName2Type(valuestr); indx = getField(note, indx, "flags", false, out valuestr); if (indx < 0) return null; if (!uint.TryParse(valuestr, NumberStyles.HexNumber, Culture.NumberFormatInfo, out uint flags)) return null; limit = note.Length - indx; if (limit > 120) limit = 120; indx = note.IndexOf('}', indx, limit); // skip sale if (indx < 0) return null; indx = getField(note, indx, "name", true, out valuestr); if (indx < 0) return null; string name = valuestr; indx = getField(note, indx, "desc", true, out valuestr); if (indx < 0) return null; string desc = valuestr; InventoryItemBase item = new InventoryItemBase(); item.AssetID = assetID; item.AssetType = (sbyte)assetType; item.BasePermissions = basemask; item.CreationDate = Util.UnixTimeSinceEpoch(); item.CreatorData = ""; item.CreatorId = creatorID.ToString(); item.CurrentPermissions = ownermask; item.Description = desc; item.Flags = flags; item.Folder = UUID.Zero; item.GroupID = UUID.Zero; item.GroupOwned = false; item.GroupPermissions = groupmask; item.InvType = (sbyte)invType; item.Name = name; item.NextPermissions = nextownermask; item.Owner = ownerID; item.SalePrice = 0; item.SaleType = (byte)SaleType.Not; item.ID = UUID.Random(); return item; } public static List GetEmbeddedAssetIDs(byte[] data) { if (data == null || data.Length < 79) return null; string note = Util.UTF8.GetString(data); if (String.IsNullOrWhiteSpace(note)) return null; // waste some time checking rigid versions string tmpStr = note.Substring(0, 21); if (!tmpStr.Equals("Linden text version 2")) return null; tmpStr = note.Substring(24, 25); if (!tmpStr.Equals("LLEmbeddedItems version 1")) return null; tmpStr = note.Substring(52,5); if (!tmpStr.Equals("count")) return null; int limit = note.Length - 57 - 2; if (limit > 8) limit = 8; int indx = note.IndexOfAny(seps, 57, limit); if(indx < 0) return null; if (!int.TryParse(note.Substring(57, indx - 57), out int count)) return null; List ids = new List(); while(count > 0) { string valuestr; UUID assetID = UUID.Zero; indx = note.IndexOf('}',indx); // skip to end of permissions if (indx < 0) return null; int curindx = indx; indx = getField(note, indx, "asset_id", false, out valuestr); if (indx < 0) { indx = getField(note, curindx, "shadow_id", false, out valuestr); if (indx < 0) return null; if (!UUID.TryParse(valuestr, out assetID)) return null; assetID = deMoronize(assetID); } else { if (!UUID.TryParse(valuestr, out assetID)) return null; } ids.Add(assetID); indx = note.IndexOf('}', indx); // skip to end of sale if (indx < 0) return null; indx = getField(note, indx, "name", false, out valuestr); // avoid name contents if (indx < 0) return null; indx = getField(note, indx, "desc", false, out valuestr); // avoid desc contents if (indx < 0) return null; if(count > 1) { indx = note.IndexOf("ext char index", indx); // skip to next if (indx < 0) return null; } --count; } indx = note.IndexOf("Text length",indx); if(indx > 0) { indx += 14; List textIDs = Util.GetUUIDsOnString(ref note, indx, note.Length - indx); if(textIDs.Count > 0) ids.AddRange(textIDs); } if (ids.Count == 0) return null; return ids; } /// /// Parse a notecard in Linden format to a list of ordinary lines for LSL /// /// /// public static string[] ParseNotecardToArray(byte[] data) { // check of a valid notecard if (data == null || data.Length < 79) return new string[0]; //LSL can't read notecards with embedded items if (data[58] != '0' || data[59] != '\n') return new string[0]; string note = Util.UTF8.GetString(data); if (String.IsNullOrWhiteSpace(note)) return new string[0]; // waste some time checking rigid versions string tmpStr = note.Substring(0, 21); if (!tmpStr.Equals("Linden text version 2")) return new string[0]; tmpStr = note.Substring(24, 25); if (!tmpStr.Equals("LLEmbeddedItems version 1")) return new string[0]; tmpStr = note.Substring(52, 5); if (!tmpStr.Equals("count")) return new string[0]; int indx = note.IndexOf("Text length", 60); if(indx < 0) return new string[0]; indx += 12; int end = indx + 1; for (; end < note.Length && note[end] != '\n'; ++end); if (note[end] != '\n') return new string[0]; tmpStr = note.Substring(indx, end - indx); if (!int.TryParse(tmpStr, out int textLen) || textLen == 0) return new string[0]; indx = end + 1; if (textLen + indx > data.Length) return new string[0]; // yeackk note = Util.UTF8.GetString(data, indx, textLen); textLen = note.Length; indx = 0; var lines = new List(); while (indx < textLen) { end = indx; for (; end < textLen && note[end] != '\n'; ++end); if(end == indx) lines.Add(String.Empty); else lines.Add(note.Substring(indx, end - indx)); indx = end + 1; } // notes only seem to have one text section if(lines.Count == 0) return new string[0]; return lines.ToArray(); } // libomv has old names on ATTACH_LEFT_PEC and ATTACH_RIGHT_PEC public static readonly string[] AttachmentPointNames = new string[] { string.Empty, "ATTACH_CHEST", // 1 "ATTACH_HEAD", // 2 "ATTACH_LSHOULDER", // 3 "ATTACH_RSHOULDER", // 4 "ATTACH_LHAND", // 5 "ATTACH_RHAND", // 6 "ATTACH_LFOOT", // 7 "ATTACH_RFOOT", // 8 "ATTACH_BACK", // 9 "ATTACH_PELVIS", // 10 "ATTACH_MOUTH", // 11 "ATTACH_CHIN", // 12 "ATTACH_LEAR", // 13 "ATTACH_REAR", // 14 "ATTACH_LEYE", // 15 "ATTACH_REYE", // 16 "ATTACH_NOSE", // 17 "ATTACH_RUARM", // 18 "ATTACH_RLARM", // 19 "ATTACH_LUARM", // 20 "ATTACH_LLARM", // 21 "ATTACH_RHIP", // 22 "ATTACH_RULEG", // 23 "ATTACH_RLLEG", // 24 "ATTACH_LHIP", // 25 "ATTACH_LULEG", // 26 "ATTACH_LLLEG", // 27 "ATTACH_BELLY", // 28 "ATTACH_LEFT_PEC", // 29 "ATTACH_RIGHT_PEC", // 30 "ATTACH_HUD_CENTER_2", // 31 "ATTACH_HUD_TOP_RIGHT", // 32 "ATTACH_HUD_TOP_CENTER", // 33 "ATTACH_HUD_TOP_LEFT", // 34 "ATTACH_HUD_CENTER_1", // 35 "ATTACH_HUD_BOTTOM_LEFT", // 36 "ATTACH_HUD_BOTTOM", // 37 "ATTACH_HUD_BOTTOM_RIGHT", // 38 "ATTACH_NECK", // 39 "ATTACH_AVATAR_CENTER", // 40 "ATTACH_LHAND_RING1", // 41 "ATTACH_RHAND_RING1", // 42 "ATTACH_TAIL_BASE", // 43 "ATTACH_TAIL_TIP", // 44 "ATTACH_LWING", // 45 "ATTACH_RWING", // 46 "ATTACH_FACE_JAW", // 47 "ATTACH_FACE_LEAR", // 48 "ATTACH_FACE_REAR", // 49 "ATTACH_FACE_LEYE", // 50 "ATTACH_FACE_REYE", // 51 "ATTACH_FACE_TONGUE", // 52 "ATTACH_GROIN", // 53 "ATTACH_HIND_LFOOT", // 54 "ATTACH_HIND_RFOOT" // 55 }; public static string GetAttachmentName(int point) { if(point < AttachmentPointNames.Length) return AttachmentPointNames[point]; return "Unknown"; } } }