corrade-vassal – Rev 1
?pathlinks?
/*
* ClientAO.cs: GridProxy application that acts as a client side animation overrider.
* The application will start and stop animations corresponding to the movements
* of the avatar on screen.
*
* Copyright (c) 2007 Gilbert Roulot
* All rights reserved.
*
* - 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.
* - Neither the name of the Second Life Reverse Engineering Team 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 COPYRIGHT HOLDERS AND CONTRIBUTORS "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 COPYRIGHT OWNER OR 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 System;
using System.Collections;
using System.Collections.Generic;
using System.Net;
using System.IO;
using System.Reflection;
using OpenMetaverse;
using OpenMetaverse.Packets;
using OpenMetaverse.StructuredData;
using Nwc.XmlRpc;
using GridProxy;
public class ClientAO : ProxyPlugin
{
private ProxyFrame frame;
private Proxy proxy;
private UUID[] wetikonanims = {
Animations.WALK,
Animations.RUN,
Animations.CROUCHWALK,
Animations.FLY,
Animations.TURNLEFT,
Animations.TURNRIGHT,
Animations.JUMP,
Animations.HOVER_UP,
Animations.CROUCH,
Animations.HOVER_DOWN,
Animations.STAND,
Animations.STAND_1,
Animations.STAND_2,
Animations.STAND_3,
Animations.STAND_4,
Animations.HOVER,
Animations.SIT,
Animations.PRE_JUMP,
Animations.FALLDOWN,
Animations.LAND,
Animations.STANDUP,
Animations.FLYSLOW,
Animations.SIT_GROUND_staticRAINED,
UUID.Zero, //swimming doesnt exist
UUID.Zero,
UUID.Zero,
UUID.Zero
};
private string[] wetikonanimnames = {
"walk",
"run",
"crouch walk",
"fly",
"turn left",
"turn right",
"jump",
"hover up",
"crouch",
"hover down",
"stand",
"stand 2",
"stand 3",
"stand 4",
"stand 5",
"hover",
"sit",
"pre jump",
"fall down",
"land",
"stand up",
"fly slow",
"sit on ground",
"swim (ignored)", //swimming doesnt exist
"swim (ignored)",
"swim (ignored)",
"swim (ignored)"
};
private Dictionary<UUID, string> animuid2name;
//private Assembly libslAssembly;
#region Packet delegates members
private PacketDelegate _packetDelegate;
private PacketDelegate packetDelegate
{
get
{
if (_packetDelegate == null)
{
_packetDelegate = new PacketDelegate(AnimationPacketHandler);
}
return _packetDelegate;
}
}
private PacketDelegate _inventoryPacketDelegate;
private PacketDelegate inventoryPacketDelegate
{
get
{
if (_inventoryPacketDelegate == null)
{
_inventoryPacketDelegate = new PacketDelegate(InventoryDescendentsHandler);
}
return _inventoryPacketDelegate;
}
}
private PacketDelegate _transferPacketDelegate;
private PacketDelegate transferPacketDelegate
{
get
{
if (_transferPacketDelegate == null)
{
_transferPacketDelegate = new PacketDelegate(TransferPacketHandler);
}
return _transferPacketDelegate;
}
}
// private PacketDelegate _transferInfoDelegate;
// private PacketDelegate transferInfoDelegate
// {
// get
// {
// if (_transferInfoDelegate == null)
// {
// _transferInfoDelegate = new PacketDelegate(TransferInfoHandler);
// }
// return _transferInfoDelegate;
// }
// }
#endregion
//map of built in SL animations and their overrides
private Dictionary<UUID,UUID> overrides = new Dictionary<UUID,UUID>();
//list of animations currently running
private Dictionary<UUID, int> SignaledAnimations = new Dictionary<UUID, int>();
//playing status of animations'override animation
private Dictionary<UUID, bool> overrideanimationisplaying;
//Current inventory path search
string[] searchPath;
//Search level
int searchLevel;
//Current folder
UUID currentFolder;
// Number of directory descendents received
int nbdescendantsreceived;
//List of items in the current folder
Dictionary<string,InventoryItem> currentFolderItems;
//Asset download request ID
UUID assetdownloadID;
//Downloaded bytes so far
int downloadedbytes;
//size of download
int downloadsize;
//data buffer
byte[] buffer;
public ClientAO(ProxyFrame frame)
{
this.frame = frame;
this.proxy = frame.proxy;
}
//Initialise the plugin
public override void Init()
{
//libslAssembly = Assembly.Load("libsecondlife");
//if (libslAssembly == null) throw new Exception("Assembly load exception");
// build the table of /command delegates
InitializeCommandDelegates();
SayToUser("ClientAO loaded");
}
// InitializeCommandDelegates: configure ClientAO's commands
private void InitializeCommandDelegates()
{
//The ClientAO responds to command beginning with /ao
frame.AddCommand("/ao", new ProxyFrame.CommandDelegate(CmdAO));
}
//Process commands from the user
private void CmdAO(string[] words) {
if (words.Length < 2)
{
SayToUser("Usage: /ao on/off/notecard path");
}
else if (words[1] == "on")
{
//Turn AO on
AOOn();
SayToUser("AO started");
}
else if (words[1] == "off")
{
//Turn AO off
AOOff();
SayToUser("AO stopped");
}
else
{
//Load notecard from path
//exemple: /ao Objects/My AOs/wetikon/config.txt
string[] tmp = new string[words.Length - 1];
//join the arguments together with spaces, to
//take care of folder and item names with spaces in them
for (int i = 1; i < words.Length; i++)
{
tmp[i - 1] = words[i];
}
// add a delegate to monitor inventory infos
proxy.AddDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
RequestFindObjectByPath(frame.InventoryRoot, String.Join(" ", tmp));
}
}
private void AOOn()
{
// add a delegate to track agent movements
proxy.AddDelegate(PacketType.AvatarAnimation, Direction.Incoming, this.packetDelegate);
}
private void AOOff()
{
// remove the delegate to track agent movements
proxy.RemoveDelegate(PacketType.AvatarAnimation, Direction.Incoming, this.packetDelegate);
//Stop all override animations
foreach (UUID tmp in overrides.Values)
{
Animate(tmp, false);
}
}
// Inventory functions
//start requesting an item by its path
public void RequestFindObjectByPath(UUID baseFolder, string path)
{
if (path == null || path.Length == 0)
throw new ArgumentException("Empty path is not supported");
currentFolder = baseFolder;
//split path by '/'
searchPath = path.Split('/');
//search for first element in the path
searchLevel = 0;
// Start the search
RequestFolderContents(baseFolder,
true,
(searchPath.Length == 1) ? true : false,
InventorySortOrder.ByName);
}
//request a folder content
public void RequestFolderContents(UUID folder, bool folders, bool items,
InventorySortOrder order)
{
//empty the dictionnary containing current folder items by name
currentFolderItems = new Dictionary<string, InventoryItem>();
//reset the number of descendants received
nbdescendantsreceived = 0;
//build a packet to request the content
FetchInventoryDescendentsPacket fetch = new FetchInventoryDescendentsPacket();
fetch.AgentData.AgentID = frame.AgentID;
fetch.AgentData.SessionID = frame.SessionID;
fetch.InventoryData.FetchFolders = folders;
fetch.InventoryData.FetchItems = items;
fetch.InventoryData.FolderID = folder;
fetch.InventoryData.OwnerID = frame.AgentID; //is it correct?
fetch.InventoryData.SortOrder = (int)order;
//send packet to SL
proxy.InjectPacket(fetch, Direction.Outgoing);
}
//process the reply from SL
private Packet InventoryDescendentsHandler(Packet packet, IPEndPoint sim)
{
bool intercept = false;
InventoryDescendentsPacket reply = (InventoryDescendentsPacket)packet;
if (reply.AgentData.Descendents > 0
&& reply.AgentData.FolderID == currentFolder)
{
//SayToUser("nb descendents: " + reply.AgentData.Descendents);
//this packet concerns the folder we asked for
if (reply.FolderData[0].FolderID != UUID.Zero
&& searchLevel < searchPath.Length - 1)
{
nbdescendantsreceived += reply.FolderData.Length;
//SayToUser("nb received: " + nbdescendantsreceived);
//folders are present, and we are not at end of path.
//look at them
for (int i = 0; i < reply.FolderData.Length; i++)
{
//SayToUser("Folder: " + Utils.BytesToString(reply.FolderData[i].Name));
if (searchPath[searchLevel] == Utils.BytesToString(reply.FolderData[i].Name)) {
//We found the next folder in the path
currentFolder = reply.FolderData[i].FolderID;
if (searchLevel < searchPath.Length - 1)
{
// ask for next item in path
searchLevel++;
RequestFolderContents(currentFolder,
true,
(searchLevel < searchPath.Length - 1) ? false : true,
InventorySortOrder.ByName);
//Jump to end
goto End;
}
}
}
if (nbdescendantsreceived >= reply.AgentData.Descendents)
{
//We have not found the folder. The user probably mistyped it
SayToUser("Didn't find folder " + searchPath[searchLevel]);
//Stop looking at packets
proxy.RemoveDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
}
}
else if (searchLevel < searchPath.Length - 1)
{
//There are no folders in the packet ; but we are looking for one!
//We have not found the folder. The user probably mistyped it
SayToUser("Didn't find folder " + searchPath[searchLevel]);
//Stop looking at packets
proxy.RemoveDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
}
else
{
//There are folders in the packet. And we are at the end of
//the path, count their number in nbdescendantsreceived
nbdescendantsreceived += reply.FolderData.Length;
//SayToUser("nb received: " + nbdescendantsreceived);
}
if (reply.ItemData[0].ItemID != UUID.Zero
&& searchLevel == searchPath.Length - 1)
{
//there are items returned and we are looking for one
//(end of search path)
//count them
nbdescendantsreceived += reply.ItemData.Length;
//SayToUser("nb received: " + nbdescendantsreceived);
for (int i = 0; i < reply.ItemData.Length; i++)
{
//we are going to store info on all items. we'll need
//it to get the asset ID of animations refered to by the
//configuration notecard
if (reply.ItemData[i].ItemID != UUID.Zero)
{
InventoryItem item = CreateInventoryItem((InventoryType)reply.ItemData[i].InvType, reply.ItemData[i].ItemID);
item.ParentUUID = reply.ItemData[i].FolderID;
item.CreatorID = reply.ItemData[i].CreatorID;
item.AssetType = (AssetType)reply.ItemData[i].Type;
item.AssetUUID = reply.ItemData[i].AssetID;
item.CreationDate = Utils.UnixTimeToDateTime((uint)reply.ItemData[i].CreationDate);
item.Description = Utils.BytesToString(reply.ItemData[i].Description);
item.Flags = (uint)reply.ItemData[i].Flags;
item.Name = Utils.BytesToString(reply.ItemData[i].Name);
item.GroupID = reply.ItemData[i].GroupID;
item.GroupOwned = reply.ItemData[i].GroupOwned;
item.Permissions = new Permissions(
reply.ItemData[i].BaseMask,
reply.ItemData[i].EveryoneMask,
reply.ItemData[i].GroupMask,
reply.ItemData[i].NextOwnerMask,
reply.ItemData[i].OwnerMask);
item.SalePrice = reply.ItemData[i].SalePrice;
item.SaleType = (SaleType)reply.ItemData[i].SaleType;
item.OwnerID = reply.AgentData.OwnerID;
//SayToUser("item in folder: " + item.Name);
//Add the item to the name -> item hash
currentFolderItems.Add(item.Name, item);
}
}
if (nbdescendantsreceived >= reply.AgentData.Descendents)
{
//We have received all the items in the last folder
//Let's look for the item we are looking for
if (currentFolderItems.ContainsKey(searchPath[searchLevel]))
{
//We found what we where looking for
//Stop looking at packets
proxy.RemoveDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
//Download the notecard
assetdownloadID = RequestInventoryAsset(currentFolderItems[searchPath[searchLevel]]);
}
else
{
//We didnt find the item, the user probably mistyped its name
SayToUser("Didn't find notecard " + searchPath[searchLevel]);
//TODO: keep looking for a moment, or else reply packets may still
//come in case of a very large inventory folder
//Stop looking at packets
proxy.RemoveDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
}
}
}
else if (searchLevel == searchPath.Length - 1 && nbdescendantsreceived >= reply.AgentData.Descendents)
{
//There are no items in the packet, but we are looking for one!
//We didnt find the item, the user probably mistyped its name
SayToUser("Didn't find notecard " + searchPath[searchLevel]);
//TODO: keep looking for a moment, or else reply packets may still
//come in case of a very large inventory folder
//Stop looking at packets
proxy.RemoveDelegate(PacketType.InventoryDescendents, Direction.Incoming, this.inventoryPacketDelegate);
}
//Intercept the packet, it was a reply to our request. No need
//to confuse the actual SL client
intercept = true;
}
End:
if (intercept)
{
//stop packet
return null;
}
else
{
//let packet go to client
return packet;
}
}
public static InventoryItem CreateInventoryItem(InventoryType type, UUID id)
{
switch (type)
{
case InventoryType.Texture: return new InventoryTexture(id);
case InventoryType.Sound: return new InventorySound(id);
case InventoryType.CallingCard: return new InventoryCallingCard(id);
case InventoryType.Landmark: return new InventoryLandmark(id);
case InventoryType.Object: return new InventoryObject(id);
case InventoryType.Notecard: return new InventoryNotecard(id);
case InventoryType.Category: return new InventoryCategory(id);
case InventoryType.LSL: return new InventoryLSL(id);
case InventoryType.Snapshot: return new InventorySnapshot(id);
case InventoryType.Attachment: return new InventoryAttachment(id);
case InventoryType.Wearable: return new InventoryWearable(id);
case InventoryType.Animation: return new InventoryAnimation(id);
case InventoryType.Gesture: return new InventoryGesture(id);
default: return new InventoryItem(type, id);
}
}
//Ask for download of an item
public UUID RequestInventoryAsset(InventoryItem item)
{
// Build the request packet and send it
TransferRequestPacket request = new TransferRequestPacket();
request.TransferInfo.ChannelType = (int)ChannelType.Asset;
request.TransferInfo.Priority = 101.0f;
request.TransferInfo.SourceType = (int)SourceType.SimInventoryItem;
UUID transferID = UUID.Random();
request.TransferInfo.TransferID = transferID;
byte[] paramField = new byte[100];
Buffer.BlockCopy(frame.AgentID.GetBytes(), 0, paramField, 0, 16);
Buffer.BlockCopy(frame.SessionID.GetBytes(), 0, paramField, 16, 16);
Buffer.BlockCopy(item.OwnerID.GetBytes(), 0, paramField, 32, 16);
Buffer.BlockCopy(UUID.Zero.GetBytes(), 0, paramField, 48, 16);
Buffer.BlockCopy(item.UUID.GetBytes(), 0, paramField, 64, 16);
Buffer.BlockCopy(item.AssetUUID.GetBytes(), 0, paramField, 80, 16);
Buffer.BlockCopy(Utils.IntToBytes((int)item.AssetType), 0, paramField, 96, 4);
request.TransferInfo.Params = paramField;
// add a delegate to monitor configuration notecards download
proxy.AddDelegate(PacketType.TransferPacket, Direction.Incoming, this.transferPacketDelegate);
//send packet to SL
proxy.InjectPacket(request, Direction.Outgoing);
//so far we downloaded 0 bytes
downloadedbytes = 0;
//the total size of the download is yet unknown
downloadsize = 0;
//A 100K buffer should be enough for everyone
buffer = new byte[1024 * 100];
//Return the transfer ID
return transferID;
}
// SayToUser: send a message to the user as in-world chat
private void SayToUser(string message)
{
ChatFromSimulatorPacket packet = new ChatFromSimulatorPacket();
packet.ChatData.FromName = Utils.StringToBytes("ClientAO");
packet.ChatData.SourceID = UUID.Random();
packet.ChatData.OwnerID = frame.AgentID;
packet.ChatData.SourceType = (byte)2;
packet.ChatData.ChatType = (byte)1;
packet.ChatData.Audible = (byte)1;
packet.ChatData.Position = new Vector3(0, 0, 0);
packet.ChatData.Message = Utils.StringToBytes(message);
proxy.InjectPacket(packet, Direction.Incoming);
}
//start or stop an animation
public void Animate(UUID animationuuid, bool run)
{
AgentAnimationPacket animate = new AgentAnimationPacket();
animate.Header.Reliable = true;
animate.AgentData.AgentID = frame.AgentID;
animate.AgentData.SessionID = frame.SessionID;
//We send one animation
animate.AnimationList = new AgentAnimationPacket.AnimationListBlock[1];
animate.AnimationList[0] = new AgentAnimationPacket.AnimationListBlock();
animate.AnimationList[0].AnimID = animationuuid;
animate.AnimationList[0].StartAnim = run;
animate.PhysicalAvatarEventList = new AgentAnimationPacket.PhysicalAvatarEventListBlock[0];
//SayToUser("anim " + animname(animationuuid) + " " + run);
proxy.InjectPacket(animate, Direction.Outgoing);
}
//return the name of an animation by its UUID
// private string animname(UUID arg)
// {
// return animuid2name[arg];
// }
//handle animation packets from simulator
private Packet AnimationPacketHandler(Packet packet, IPEndPoint sim) {
AvatarAnimationPacket animation = (AvatarAnimationPacket)packet;
if (animation.Sender.ID == frame.AgentID)
{
//the received animation packet is about our Agent, handle it
lock (SignaledAnimations)
{
// Reset the signaled animation list
SignaledAnimations.Clear();
//fill it with the fresh list from simulator
for (int i = 0; i < animation.AnimationList.Length; i++)
{
UUID animID = animation.AnimationList[i].AnimID;
int sequenceID = animation.AnimationList[i].AnimSequenceID;
// Add this animation to the list of currently signaled animations
SignaledAnimations[animID] = sequenceID;
//SayToUser("Animation: " + animname(animID));
}
}
//we now have a list of currently running animations
//Start override animations if necessary
foreach (UUID key in overrides.Keys)
{
//For each overriden animation key, test if its override is running
if (SignaledAnimations.ContainsKey(key) && (!overrideanimationisplaying[key] ))
{
//An overriden animation is present and its override animation
//isnt currently playing
//Start the override animation
//SayToUser("animation " + animname(key) + " started, will override with " + animname(overrides[key]));
overrideanimationisplaying[key] = true;
Animate(overrides[key], true);
}
else if ((!SignaledAnimations.ContainsKey(key)) && overrideanimationisplaying[key])
{
//an override animation is currently playing, but it's overriden
//animation is not.
//stop the override animation
//SayToUser("animation " + animname(key) + " stopped, will override with " + animname(overrides[key]));
overrideanimationisplaying[key] = false;
Animate(overrides[key], false);
}
}
}
//Let the packet go to the client
return packet;
}
//handle packets that contain info about the notecard data transfer
// private Packet TransferInfoHandler(Packet packet, IPEndPoint simulator)
// {
// TransferInfoPacket info = (TransferInfoPacket)packet;
//
// if (info.TransferInfo.TransferID == assetdownloadID)
// {
// //this is our requested tranfer, handle it
// downloadsize = info.TransferInfo.Size;
//
// if ((StatusCode)info.TransferInfo.Status != StatusCode.OK)
// {
// SayToUser("Failed to read notecard");
// }
// if (downloadedbytes >= downloadsize)
// {
// //Download already completed!
// downloadCompleted();
// }
// //intercept packet
// return null;
// }
// return packet;
// }
//handle packets which contain the notecard data
private Packet TransferPacketHandler(Packet packet, IPEndPoint simulator)
{
TransferPacketPacket asset = (TransferPacketPacket)packet;
if (asset.TransferData.TransferID == assetdownloadID) {
Buffer.BlockCopy(asset.TransferData.Data, 0, buffer, 1000 * asset.TransferData.Packet,
asset.TransferData.Data.Length);
downloadedbytes += asset.TransferData.Data.Length;
// Check if we downloaded the full asset
if (downloadedbytes >= downloadsize)
{
downloadCompleted();
}
//Intercept packet
return null;
}
return packet;
}
private void downloadCompleted()
{
//We have the notecard.
//Stop looking at transfer packets
proxy.RemoveDelegate(PacketType.TransferPacket, Direction.Incoming, this.transferPacketDelegate);
//crop the buffer size
byte[] tmp = new byte[downloadedbytes];
Buffer.BlockCopy(buffer, 0, tmp, 0, downloadedbytes);
buffer = tmp;
String notecardtext = getNotecardText(Utils.BytesToString(buffer));
//Load config, wetikon format
loadWetIkon(notecardtext);
}
private void loadWetIkon(string config)
{
//Reinitialise override table
overrides = new Dictionary<UUID,UUID>();
overrideanimationisplaying = new Dictionary<UUID, bool>();
animuid2name = new Dictionary<UUID,string>();
foreach (UUID key in wetikonanims )
{
animuid2name[key] = wetikonanimnames[Array.IndexOf(wetikonanims, key)];
}
//list of animations in wetikon
//read every second line in the config
char[] sep = { '\n' };
string[] lines = config.Split(sep);
int length = lines.Length;
int i = 1;
while (i < length) {
//Read animation name and look it up
string animname = lines[i].Trim();
//SayToUser("anim: " + animname);
if (animname != "")
{
if (currentFolderItems.ContainsKey(animname))
{
UUID over = currentFolderItems[animname].AssetUUID;
UUID orig = wetikonanims[((i + 1) / 2) - 1];
//put it in overrides
animuid2name[over] = animname;
overrides[orig] = over;
overrideanimationisplaying[orig] = false;
//SayToUser(wetikonanimnames[((i + 1) / 2) - 1] + " overriden by " + animname + " ( " + over + ")");
}
else
{
//Not found
SayToUser(animname + " not found.");
}
}
i += 2;
}
SayToUser("Notecard read, " + overrides.Count + " animations found");
}
private string getNotecardText(string data)
{
// Version 1 format:
// Linden text version 1
// {
// <EmbeddedItemList chunk>
// Text length
// <ASCII text; 0x80 | index = embedded item>
// }
// Version 2 format: (NOTE: Imports identically to version 1)
// Linden text version 2
// {
// <EmbeddedItemList chunk>
// Text length
// <UTF8 text; FIRST_EMBEDDED_CHAR + index = embedded item>
// }
int i = 0;
char[] sep = { '\n' };
string[] lines = data.Split(sep);
int length = lines.Length;
string result = "";
//check format
if (!lines[i].StartsWith("Linden text version "))
{
SayToUser("error");
return "";
}
//{
i++;
if (lines[i] != "{")
{
SayToUser("error");
return "";
}
i++;
if (lines[i] != "LLEmbeddedItems version 1")
{
SayToUser("error");
return "";
}
//{
i++;
if (lines[i] != "{")
{
SayToUser("error");
return "";
}
//count ...
i++;
if (!lines[i].StartsWith("count "))
{
SayToUser("error");
return "";
}
//}
i++;
if (lines[i] != "}")
{
SayToUser("error");
return "";
}
//Text length ...
i++;
if (!lines[i].StartsWith("Text length "))
{
SayToUser("error");
return "";
}
i++;
while (i < length)
{
result += lines[i] + "\n";
i++;
}
result = result.Substring(0, result.Length - 3);
return result;
}
}