Stratos: Punto de Encuentro de Desarrolladores

¡Bienvenido a Stratos!

Acceder

Foros





XML

Iniciado por warwolf, 13 de Noviembre de 2006, 10:20:12 PM

« anterior - próximo »

warwolf

Buenas Azazel,

Sé que no tiene mucho tiempo, pero.... sería posible que me hicieras una mini introducción/tutorial para poder leer archivos XML poder modificarlos y guardarlos en el DPF?  Es que no me acabo de aclarar  :oops:

Muchas gracias :D

zupervaca

Para leerlos tengo montada una clase muy sencillita en la libreria multitodo llamada dib, las unicas clases de las que depende que yo recuerde es de la clase stream para poder leer de forma abstracta de disco o memoria, la clase object y la coleccion array (esta ultima necesita el interfaz Enumerator), tambien la tengo montada para moviles, si quieres puedes usarla en tu juego o como referencia.
Esta en System::IO::XML::NodeReader, las demas clases XML que ves hay es para leer formato XML con estructuras, es muy pontente ya que puedes generar lo que quieras con solo especificar estructuras, pero a la vez puede serte complicado de entender.

Te pongo esto por que no se si Azazel tendra algo de xml en su motor, que me imagino que si.

warwolf

Bueno, la verdad es que la librería de Azazel tiene bastantes métodos para gestionar los XMLs. Mirando la documentación he visto estos:
int  XMLAttributeGet (int id, char *name, double *value)
 Get the double value of the given attribute.

int  XMLAttributeGet (int id, char *name, int *value)
 Get the integer value of the given attribute.

int  XMLAttributeGet (int id, char *name, char **value)
 Get the string value of the given attribute.

int  XMLAttributeRemove (int id, char *name)
 Remove an attribute.

int  XMLAttributeSet (int id, char *name, double value)
 Set a double value of a given attribute. If that attribute does not exist, a new one is created.

int  XMLAttributeSet (int id, char *name, int value)
 Set an integer value of a given attribute. If that attribute does not exist, a new one is created.

int  XMLAttributeSet (int id, char *name, char *value)
 Set a string value of a given attribute. If that attribute does not exist, a new one is created.

void  XMLClose (int id)
 Close a given XML.

int  XMLNodeChild (int id)
 Point to the first child node of the XML.

int  XMLNodeCreate (int id, char *name)
 Create a new node on current pointer.

int  XMLNodeFirst (int id)
 Point to the first node of the XML (the root node).

char *  XMLNodeGetName (int id)
 Get the name of current node.

int  XMLNodeNext (int id)
 Point to the next node of the XML.

int  XMLNodeParent (int id)
 Point to the parent node of the XML.

int  XMLNodePointTo (int id, int nparam, char *,...)
 Search and point to a given node or subnode of any level.

int  XMLNodeRemove (int id)
 Remove current node pointed to and all its nodes and attributes.

int  XMLNodeRename (int id, char *name)
 Rename current node pointed to.

int  XMLOpen (char *fileDPF, char *blockname)
 Open a XML file stored on a DPF.

int  XMLOpen (char *filename)
 Open a XML file.

int  XMLSave (int id, char *fileDPF, char *blockname)
 Save a given XML to a DPF.

int  XMLSave (int id, char *filename)
 Save a given XML to external file.

char *  XMLTextGet (int id)
 Return text contained on current node. Only get the first text found.

int  XMLTextRemove (int id)
 Remove text contained on current node. Only remove the first text found.

int  XMLTextSet (int id, char *value)
 Set new text contained on current node. Only change the first text found.  
Pero ponía el mensaje para ver si me puede dar un ejemplo sencillito para entender todo rápido y no tener que ir "trasteando" las funciones para ver como funcionan. Aún que la verdad parecen estar bien explicadas lo que hacen. Así que por eso decía que si tenía algún hueco pues me pusiera un par de ejemplos básicos, sino ya ire trasteando yo durante la semana ;)

PD. En un principio utilizaré los XML para guardar la configuración de las teclas y que se puedan cambiar dentro del juego (y almacenar en dicho XML para que no se tengan que ir cambiando siempre). Pero la intención es hacer un uso frecuente de XML en archivos de configuración y cosas similares ;)

TheAzazel

Mañana te pongo un ejemplillo rapido de como crear/cargar un XML, leer valores, escribir nuevos o reescribir y luego guardar a XML o un XML embebido en un DPF. Esto ultimo es comodo porque luego desde el EditorDPF puedes modificarlos directamente y a la vez quedara protegido...

zuper, tu parser XML esta completo? porque yo cuando estuve buscando uno, me quede con tinyxml porque tenia tela hacer uno completo y con toda la gestion de errores... si es asi, menuda panzada te diste!

Saludos

zupervaca

Que va, es muy sencillito, no comprueba errores, la clase nodereader es la clasica que te permite leer los tags de forma secuencial, no obstante si el formato del xml es correcto funciona muy bien.

Mira este es el codigo:

// nodereader.h

// Por David Inclán Blanco
// http://www.davidib.com
// Este codigo esta protegido bajo licencia LGPL

#ifndef _dib_System_IO_XML_NodeReader
#define _dib_System_IO_XML_NodeReader

#include "../../object.h"
#include "../istream.h"
#include "../../collection/array.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>

namespace dib
{
namespace System
{
namespace IO
{
namespace XML
{
/// <summary>Clase para leer nodos de un xml de forma ordenada</summary>
/// <remarks>
/// Comportamiento de la clase:\n
/// - La clase siempre retornara el tag siguiente y si tiene un valor este\n
/// - Los cierres se notifican teniendo la / delante del tag que se esta cerrando\n
/// - Los comentarios retornan el tag "<!--" y su valor es todo el comentario, los comentarios no tienen
/// tag de cierre
/// </remarks>
class NodeReader : public Object
{
// Metodos
public:
/// <summary>Constructor</summary>
NodeReader()
{
this->stream = NULL;
}

/// <summary>Constructor</summary>
/// <param name="stream">Stream a tratar</param>
NodeReader( IStream* stream )
{
this->stream = stream;
}

/// <summary>Indicar el origen del XML</summary>
/// <param name="stream">Stream a tratar</param>
void SetStream( IStream* stream )
{
// Seleccionar el stream
this->stream = stream;
}

/// <summary>Obtener el siguiente y tag y valor</summary>
/// <returns>True si se obtuvo, si no quedan mas tags false</returns>
bool ReadNext()
{
// Limpiar los strings
this->tag.Empty();
this->value.Empty();
// Buscar un tag tag
if( this->SearchTag() )
{
// Insertar el fin de string en los strings
this->tag.Add( '\0' );
this->value.Add( '\0' );
// Ok
return true;
}
return false;
}

/// <summary>Obtener el tag</summary>
/// <returns>Puntero al string que contiene el tag que se ha leido al llamar a ReadNext</returns>
const char* GetReadTag()
{
return (const char*)this->tag.BeginPtr();
}

/// <summary>Obtener el valor</summary>
/// <returns>Puntero al string que contiene el tag que se ha leido al llamar a ReadNext</returns>
const char* GetReadValue()
{
return (const char*)this->value.BeginPtr();
}

/// <summary>Obtener el stream</summary>
/// <returns>Stream</returns>
dib::System::IO::IStream* GetStream()
{
return this->stream;
}

// Metodos privados
private:
/// <summary>Buscar el comienzo de un tag</summary>
/// <returns>True no ocurrio ningun error, false se termino el origen o ocurrio algun error</returns>
bool SearchTag()
{
// Comprobar si hay alguna variable de tag sin cerrar
if( !this->lastVarTag.IsEmpty() )
{
// Tenemos una variable sin cerrar, enviarla sin hacer nada
this->tag.Add( this->lastVarTag );
this->lastVarTag.Empty();
return true;
}
// Saltarse la basura e ir buscando un tag
while( this->JumpGarbage() )
{
if( this->car == '/' || this->car == '?' )
{
// Es el cierre del anterior tag a este
this->tag.Add( this->closeTag );
this->closeTag.Empty();
this->lastVarTag.Empty();
return true;
}
if( this->car != '<' )
{
// Agregar este caracter al array
this->tag.Add( this->car );
}
// Buscar el final del tag
while( this->GetCar() )
{
switch( this->car )
{
case '!':
// Pueden ser varias cosas
if( this->GetCar() )
{
if( car == '-' )
{
// Es un comentario
this->tag.Add( (char*)"<!--", 4 );
this->stream->Foward( 1 );
this->GetValue( "-->", this->value );
return this->stream->Foward(-1);
}
}
break;
// El valor del tag se encuentra hasta el cierre de este o la apertura de otro
case '>':
if( this->GetValue('<', this->value) )
{
// Hay que retroceder ya que los cierres se cuentan como tag
return this->stream->Foward(-1);
}
return true;

// Tiene variables
case ' ':
// Memorizar el cierre con su tag
this->closeTag.Empty();
this->closeTag.Add( '/' );
this->closeTag.Add( this->tag );
return true;

// El tag es una variable, hay que obtener su valor que esta entre comillas dobles
case '=':
// Buscar las comillas dobles o las simples
while( this->GetCar() )
{
if( this->car == '\"' || this->car == '\'' )
{
// Alertar que es un tag de variable
this->lastVarTag.Add( '/' );
this->lastVarTag.Add( this->tag );
this->lastVarTag.Add( '\0' );
// Ahora que nos encontramos las primeras comillas memorizamos todo su valor
return this->GetValue( this->car, this->value );
}
}
return false;

// Seguimos memorizando el nombre del tag
default:
this->tag.Add( this->car );
break;
};
}
}
return false;
}

/// <summary>Obtener el valor del tag</summary>
/// <returns>True no ocurrio ningun error, false se termino el origen o ocurrio algun error</returns>
bool GetValue( char* end, dib::System::Collection::Array<char>& value )
{
char* endCurrent = end;
while( this->GetCar() )
{
if( this->car == '&' )
{
if( !this->Convert() )
{
// La entidad no fue resuelta
value.Add( this->car );
}
}
else
{
// Agregar el caracter
value.Add( this->car );
}
// Comprobar el string
if( this->car == *endCurrent )
{
endCurrent++;
if( *endCurrent == '\0' )
{
// Se encontro el final
int endPos = value.Count() - (int)(long long)(endCurrent - end);
value.SetValue( endPos, '\0' );
return true;
}
}
else
{
// No es el que buscamos, seguir
endCurrent = end;
}
}
// Error
return false;
}

/// <summary>Obtener el valor del tag</summary>
/// <returns>True no ocurrio ningun error, false se termino el origen o ocurrio algun error</returns>
bool GetValue( char end, dib::System::Collection::Array<char>& value )
{
while( this->GetCar() )
{
if( this->car != end )
{
// Mirar si es un valor especial
if( this->car == '&' )
{
// Obtener el valor especial
if( !this->Convert() )
{
if( this->GetCar() )
{
value.Add( this->car );
}
}
}
else
{
// Memorizarlo
value.Add( this->car );
}
}
else
{
// Se encontro
value.Add( '\0' );
return true;
}
}
// Error
return false;
}

/// <summary>Convertir el caracter especial</summary>
/// <returns>True no ocurrio ningun error, false se termino el origen o ocurrio algun error</returns>
bool Convert()
{
// Memorizar la posicion por si no se reconoce
int savePosition = this->stream->GetPosition() - 1;
// Obtener la entidad
dib::System::Collection::Array<char> entity;
this->GetValue( ';', entity );
entity.Add( '\0' );
// Comprobar si es el caso de #
if( entity.GetValue(0) == '#' )
{
// Es una entidad numerica
int number = atoi( entity.BeginPtr() + 1 );
char sz[2];
sprintf( sz, "%c", number );
this->value.Add( sz[0] );
return true;
}
else
{
// Es un string
const char* strEntity = entity.BeginPtr();
Entity* entity = (Entity*)&this->entities[0];
while( entity->entity != NULL )
{
if( strcmp(entity->entity, strEntity) == 0 )
{
// Se encontro
this->value.Add( entity->value );
return true;
}
// Siguiente
entity++;
}
}
// No fue reconocido, restaurar la posicion anterior
this->stream->SetPosition( savePosition );
return false;
}

/// <summary>Saltarse todo tipo de basura</summary>
/// <returns>True no ocurrio ningun error, false se termino el origen o ocurrio algun error</returns>
bool JumpGarbage()
{
while( this->GetCar() )
{
// Saltarse todos estos caracteres
if( this->car != '\r' && this->car != '\n' && this->car != '\t' &&
this->car != ' ' && this->car != '>' )
{
return true;
}
};
// Error
return false;
}

/// <summary>Leer el siguiente caracter</summary>
/// <returns>True si se ha leido, false si ocurrio algun error</returns>
/// <remarks>Su valor se almacena en la variable "car" de la clase</summary>
bool GetCar()
{
return this->stream->Get( &this->car );
}

// Estructuras privadas
private:
/// <summary>Estructura que almacena una entidad y su cambio</summary>
struct Entity
{
const char* entity;
const char value;
};

// Valores privados
private:
/// <summary>Stream que esta siendo usado por la clase</summary>
IStream* stream;
/// <summary>Caracter que esta siendo leido</summary>
char car;
/// <summary>Tag</summary>
dib::System::Collection::Array<char> tag;
/// <summary>Valor del tag</summary>
dib::System::Collection::Array<char> value;
/// <summary>Tag anterior que no tenia valor</summary>
dib::System::Collection::Array<char> closeTag;
/// <summary>Entidades constantes</summary>
static const Entity entities[];
/// <summary>Ultimo tag variable tratado, si esta vacio no se envia el cierre, si tiene contenido se envia</summary>
dib::System::Collection::Array<char> lastVarTag;
};

/// <summary>Entidades constantes</summary>
const NodeReader::Entity NodeReader::entities[] =
{
{"quot", '\"'},
{"amp", '&'},
{"lt", '<'},
{"gt", '>'},
{"nbsp", ' '},
{"copy", '©'},
{"Agrave", 'À'},
{"Aacute", 'Á'},
{"Acirc", 'Â'},
{"Atilde", 'Ã'},
{"Auml", 'Ä'},
{"Aring", 'Å'},
{"AElig", 'Æ'},
{"Ccedil", 'Ç'},
{"Egrave", 'È'},
{"Eacute", 'É'},
{"Ecirc", 'Ê'},
{"Euml", 'Ë'},
{"Igrave", 'Ì'},
{"Iacute", 'Í'},
{"Icirc", 'Î'},
{"Iuml", 'Ï'},
{"ETH", 'Ð'},
{"Ntilde", 'Ñ'},
{"Ograve", 'Ò'},
{"Oacute", 'Ó'},
{"Ocirc", 'Ô'},
{"Otilde", 'Õ'},
{"Ouml", 'Ö'},
{"Oslash", 'Ø'},
{"Ugrave", 'Ù'},
{"Uacute", 'Ú'},
{"Ucirc", 'Û'},
{"Uuml", 'Ü'},
{"Yacute", 'Ý'},
{"THORN", 'Þ'},
{"szlig", 'ß'},
{"agrave", 'à'},
{"aacute", 'á'},
{"acirc", 'â'},
{"atilde", 'ã'},
{"auml", 'ä'},
{"aring", 'å'},
{"aelig", 'æ'},
{"ccedil", 'ç'},
{"egrave", 'è'},
{"eacute", 'é'},
{"ecirc", 'ê'},
{"euml", 'ë'},
{"igrave", 'ì'},
{"iacute", 'í'},
{"icirc", 'î'},
{"iuml", 'ï'},
{"eth", 'ð'},
{"ntilde", 'ñ'},
{"ograve", 'ò'},
{"oacute", 'ó'},
{"ocirc", 'ô'},
{"otilde", 'õ'},
{"ouml", 'ö'},
{"oslash", 'ø'},
{"ugrave", 'ù'},
{"uacute", 'ú'},
{"ucirc", 'û'},
{"uuml", 'ü'},
{"yacute", 'ý'},
{"thorn", 'þ'},
{"yuml", 'ÿ'},
{NULL, 0},
};
}
}
}
}

#endif

Lo bueno de esta clase es que le indicas el origen y despues con un bucle de este tipo interpretarias todo el xml:

while( nodereader.ReadNext() )
{
const char* tag = nodereader.GetReadTag();
const char* value = nodereader.GetReadValue();

// comprobar tags
....
}

Un xml de este tipo:

<usuario nombre="pepe">
<clave>pepe</clave>
<direccion telefono="xxxxxxx" />
</usuario>

te lo lee perfectamente, el resultado obtenido con GetReadTag seria este:

tag=usuario;
tag=nombre; value=pepe
tag=/nombre;
tag=clave; value=pepe
tag=/clave;
tag=direccion;
tag=telefono; value=xxxxxxx
tag=/telefono;
tag=/direccion;
tag=/usuario

Como ves es muy sencillo, pero al mismo tiempo practico

Harko

A mi tambien me interesa saber mas cosas sobre esto.

Se podria utilizar para guardar ahi los dialogos, introducciones y todos esos textos largos que se suelen poner en un juego, no? O seria mejor utlilizar otro tipo de archivo? A no ser que siempre se pongan los textos dentro del codigo :D.

Harko.
-=Harko´s Blog=-
Fui el primer civil en probar el "Lord of Creatures" y ademas usaban mis cascos. :D

-=Portfolio=-

Alguno de mis juegos:
-=Feed The Frog=-

Neroncity

zupervaca

Cita de: "Harko"Se podria utilizar para guardar ahi los dialogos, introducciones y todos esos textos largos que se suelen poner en un juego, no? O seria mejor utlilizar otro tipo de archivo?
Normalmente yo uso xml y los leo todos al inicio, como mucho puedes perder 1 mega de memoria ram siendo unos 4000 textos de 256 caracteres todos, algo totalmente permitible :wink:, ten en cuenta que no todos los textos ocupan 256 caracteres siendo la mayoria de las veces textos de 50 caracteres como mucho, eso si, usa indices numericos o en su defecto tablas hash.

Editado: Si quieres para evitar fragmentar mucho la memoria puedes usar un pequeño truco que aun no he implementado nunca, siempre que los textos sean constantes puedes meterlos todos en un mismo array de caracteres y tener el un array de indices que indica el offset dentro de ese array de caracteres.
Algo asi:

Pepe como te va\0Hola tio\0Comenzar juego\0

Despues el array de indices tendria esto:

indices[0] = 0
indices[1] = 16
indices[2] = 26

Para meterlos hay puedes hacerlo de dos maneras, creando un array de caracteres muy grande, ir metiendolos todos y luego ya sabiendo su tamaño total copiarlo a otro array con ese tamaño justo.
Recuerda que la pila no es muy grande con lo que tendras que usar "new" para estos arrays.
La otra manera es con array dinamico.

Se me olvidaba mas cosas, para evitar usar strlen (algunas veces puedes que lo necesitaras por cualquier cosa, centrar el texto en X, etc.), puedes poner que los dos o cuatro primeros bytes del string sean su tamaño, recuerda que si estas haciendo algo en multiplataforma puede que tengas que rotar estos dos o cuatro bytes por el orden de estos.

Editado 2: Hoy me siento con fuerzas jeje, si por cualquier cosa no te convence el sistema siempre puedes crear una clase base para administrar los textos, algo asi:

class Idioma
{
public:
   struct Text
   {
       int length;
       char* text;  // Si fuera unicode pues ya sabes ;)
   };

   virtual Text* Get( int index ) = 0;
};

Ahora teniendo esa clase que no sabe trabajar con nada ya puedes implementar varios sistemas y el que mas te guste es el que usarias, para el anterior seria algo asi:

class IdiomaArray : public Idioma
{
public:
   IdiomaArray( Stream* origen )
   {
       // De un origen se sacarian todos los textos por ejemplo
       ...
   }

   Text* Get( int index )
   {
       Text* ret = (Text*)(*(idioma + (*(indices + index))));
       //ret->length = rotarbytes(ret->length); // Recuerda que lo puedes hacer cuando estas metiendo los strings en el array general de strings
       return ret;        
   }

private:
   int* indices;
   char* idioma;
}

Pogacha

No lo puedes, lo debes cuando ...

TheAzazel

Siento no estar muy activo esta semana pero me va a ser imposible contestar antes del finde.

Mucho lio en el curro.. :S

warwolf

Naaaa, minetras contestes y despues no me presioneis a mi para que saque novedades no hay problema :P jejeje

TheAzazel

Buenas! ahora que tengo un ratito, me pongo al lio con esto y una cosa menos.

Un XML vacio podria ser esto:
<?xml version="1.0" encoding="UTF-8" ?>
<document>
</document>


y para nuestros ejemplos, vamos a usar el siguiente:
<?xml version="1.0" encoding="UTF-8" ?>
<document>
   <English name="name" value="value">hello</English>
   <Spanish name="myname" value="50" valuef="222.4">This is my text, it could be anything!</Spanish>
</document>


Aqui, nuestro primer y ultimo nodo seria <document>, que ademas, no tiene ningun atributo ni ningun texto. Hijos de este nodo serian los nodos <english> y <spanish>, y asi podriamos tener tantos hijos como queramos. Ahora, nos fijaremos en el nodo <english>, este tiene dos atributos, "name" y "value" de texto(no confundir un atributo de texto con un texto eh?) y tambien tiene un texto: "hello". Si miramos al siguiente nodo, <spanish>, este tiene 3 atributos, uno de cada tipo: texto, entero y decimal, y ademas, un texto.

Bueno, una vez visto eso, os doy una pequeña descripcion basada en nuestro xml de ejemplo:

- Lo primero es abrir un XML, con estas dos funciones lo conseguimos:
int XMLOpen(char *filename);
int XMLOpen(char *fileDPF,char *blockname);

Esas dos funciones, retornan error(0) o el ID del XML. Ese ID debemos guardarlo porque es lo que se usara en el resto de funciones.

- Ahora podemos navegar por el XML, para ello, utilizaremos el concepto de un puntero que podemos ir moviendo. Al inicio apunta justo antes del primer nodo, en nuestro caso, antes de <document>.
Si quisieramos apuntar al primer nodo, nos basta con usar:
int CRM32Pro.XMLNodeFirst(int id);
Este siempre apuntara al nodo padre del XML, en nuestro caso, <document>
Si quisieramos el siguiente:
int XMLNodeNext(int id);
Y asi con el resto de funciones de los nodos:
int XMLNodeParent(int id); -> vuelve a apuntar al nodo padre.
int XMLNodeChild(int id);   -> apunta al primer nodo hijo
Con Next podemos movernos dentro de los parents o los children como queramos :)
Puestos en este caso, podemos crear, renombrar o borrar nodos con:
int XMLNodeCreate(int id, char *name);
int XMLNodeRename(int id, char *name);
int XMLNodeRemove(int id);
Por supuesto, se hara de acuerdo a donde este nuestro puntero.
Si queremos saber donde nos encontramos, podemos usar:
char *XMLNodeGetName(int id);
que nos devolvera el nodo actual al que apuntamos.
Para terminar con los nodos, nos queda:
int XMLNodePointTo(int id,int nparam,char *, ...);
que añadi porque me parece bastante comodo si sabemos donde queremos ir, asi no tenemos que estar con First, Next, Child o Parent...
simplemente le indicamos como parametros los nodos a los que queremos ir, eso si, necesita el numero de parametros. Por ejemplo, si queremos ir directamente al nodo <spanish> nos bastaria con:
XMLNodePointTo(id,2,"document","spanish");

- Ya que sabemos movernos por los nodos...vamos a acceder a los atributos del nodo al que apuntamos:
int XMLAttributeSet(int id,char *name,char *value);
int XMLAttributeSet(int id,char *name,int value);
int XMLAttributeSet(int id,char *name,double value);
int XMLAttributeGet(int id,char *name, char **value);
int XMLAttributeGet(int id,char *name, int *value);
int XMLAttributeGet(int id,char *name, double *value);
Creo que esto esta claro, un Get para obtener el atributo y un set para o bien crearlo si no existe o bien cambiarlo de valor.
La ultima funcion que nos queda es..eliminar un atributo con:
int XMLAttributeRemove(int id, char *name);

- Y ahora con los textos:
char *XMLTextGet(int id);
int XMLTextSet(int id, char *value);
int XMLTextRemove(int id);
Facil no? :)
      
- Antes de terminar, si hemos modificado algo del XML, podemos grabarlo externamente tanto a un fichero como dentro de un DPF con
int XMLSave(int id, char *filename);
int XMLSave(int id, char *fileDPF, char *blockname);

- El paso final es..cerrar el XML, tanto si hemos modificado algo como si solo hemos leido...debemos cerrar el XML y listo!
void XMLClose(int id);

Esto fue todo, como limitaciones...tiene una con respecto a los textos, pero para temas de configuracion de juegos o demas, normalmente habra solo un texto... porque lo que no admite es si ponemos varios textos en un nodo...hoy por hoy, solo se hace referencia al primero en aparecer, espero que ninguno encuentre esta limitacion :).

Ah, por cierto, habia un par de bugs tontos que ya he corregido en la nueva version, si, esa que llevo semanas con ella...la 4.97 jejeje

Saludos!






Stratos es un servicio gratuito, cuyos costes se cubren en parte con la publicidad.
Por favor, desactiva el bloqueador de anuncios en esta web para ayudar a que siga adelante.
Muchísimas gracias.