Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Fetching contributors…

Cannot retrieve contributors at this time

916 lines (770 sloc) 23.504 kb
/*
* See Licensing and Copyright notice in naev.h
*/
/**
* @file opengl_tex.c
*
* @brief This file handles the opengl texture wrapper routines.
*/
#include "opengl.h"
#include "naev.h"
#include <stdlib.h>
#include <stdio.h>
#include "nstring.h"
#include "log.h"
#include "ndata.h"
#include "nfile.h"
#include "gui.h"
#include "conf.h"
#include "npng.h"
#include "md5.h"
/*
* graphic list
*/
/**
* @brief Represents a node in the texture list.
*/
typedef struct glTexList_ {
struct glTexList_ *next; /**< Next in linked list */
glTexture *tex; /**< associated texture */
int used; /**< counts how many times texture is being used */
} glTexList;
static glTexList* texture_list = NULL; /**< Texture list. */
/*
* Extensions.
*/
static int gl_tex_ext_npot = 0; /**< Support for GL_ARB_texture_non_power_of_two. */
/*
* prototypes
*/
/* misc */
/*static int SDL_VFlipSurface( SDL_Surface* surface );*/
static int SDL_IsTrans( SDL_Surface* s, int x, int y );
static uint8_t* SDL_MapTrans( SDL_Surface* s, int w, int h );
static size_t gl_transSize( const int w, const int h );
/* glTexture */
static GLuint gl_loadSurface( SDL_Surface* surface, int *rw, int *rh, unsigned int flags, int freesur );
static glTexture* gl_loadNewImage( const char* path, unsigned int flags );
/* List. */
static glTexture* gl_texExists( const char* path );
static int gl_texAdd( glTexture *tex );
/**
* @brief Gets the closest power of two.
* @param n Number to get closest power of two to.
* @return Closest power of two to the number.
*/
int gl_pot( int n )
{
int i = 1;
while (i < n)
i <<= 1;
return i;
}
#if 0
/**
* @brief Flips the surface vertically.
*
* @param surface Surface to flip.
* @return 0 on success.
*/
static int SDL_VFlipSurface( SDL_Surface* surface )
{
/* flip the image */
Uint8 *rowhi, *rowlo, *tmpbuf;
int y;
tmpbuf = malloc(surface->pitch);
if ( tmpbuf == NULL ) {
WARN("Out of memory");
return -1;
}
rowhi = (Uint8 *)surface->pixels;
rowlo = rowhi + (surface->h * surface->pitch) - surface->pitch;
for (y = 0; y < surface->h / 2; ++y ) {
memcpy(tmpbuf, rowhi, surface->pitch);
memcpy(rowhi, rowlo, surface->pitch);
memcpy(rowlo, tmpbuf, surface->pitch);
rowhi += surface->pitch;
rowlo -= surface->pitch;
}
free(tmpbuf);
/* flipping done */
return 0;
}
#endif
/**
* @brief Checks to see if a position of the surface is transparent.
*
* @param s Surface to check for transparency.
* @param x X position of the pixel to check.
* @param y Y position of the pixel to check.
* @return 0 if the pixel isn't transparent, 0 if it is.
*/
static int SDL_IsTrans( SDL_Surface* s, int x, int y )
{
int bpp;
Uint8 *p;
Uint32 pixelcolour;
bpp = s->format->BytesPerPixel;
/* here p is the address to the pixel we want to retrieve */
p = (Uint8 *)s->pixels + y*s->pitch + x*bpp;
pixelcolour = 0;
switch(bpp) {
case 1:
pixelcolour = *p;
break;
case 2:
pixelcolour = *(Uint16 *)p;
break;
case 3:
#if HAS_BIGENDIAN
pixelcolour = p[0] << 16 | p[1] << 8 | p[2];
#else /* HAS_BIGENDIAN */
pixelcolour = p[0] | p[1] << 8 | p[2] << 16;
#endif /* HAS_BIGENDIAN */
break;
case 4:
pixelcolour = *(Uint32 *)p;
break;
}
/* test whether pixels colour == colour of transparent pixels for that surface */
return ((pixelcolour & s->format->Amask) < (Uint32)(0.1*(double)s->format->Amask));
}
/**
* @brief Maps the surface transparency.
*
* Basically generates a map of what pixels are transparent. Good for pixel
* perfect collision routines.
*
* @param s Surface to map it's transparency.
* @param w Width to map.
* @param h Height to map.
* @return 0 on success.
*/
static uint8_t* SDL_MapTrans( SDL_Surface* s, int w, int h )
{
int i,j;
size_t size;
uint8_t *t;
/* Get limit.s */
if (w < 0)
w = s->w;
if (h < 0)
h = s->h;
/* alloc memory for just enough bits to hold all the data we need */
size = gl_transSize(w, h);
t = malloc(size);
if (t==NULL) {
WARN("Out of Memory");
return NULL;
}
memset(t, 0, size); /* important, must be set to zero */
/* Check each pixel individually. */
for (i=0; i<h; i++)
for (j=0; j<w; j++) /* sets each bit to be 1 if not transparent or 0 if is */
t[(i*w+j)/8] |= (SDL_IsTrans(s,j,i)) ? 0 : (1<<((i*w+j)%8));
return t;
}
/*
* @brief Gets the size needed for a transparency map.
*
* @param w Width of the image.
* @param h Height of the image.
* @return The size in bytes.
*/
static size_t gl_transSize( const int w, const int h )
{
/* One bit per pixel, plus remainder. */
return w*h/8 + ((w*h%8)?1:0);
}
/**
* @brief Prepares the surface to be loaded as a texture.
*
* @param surface to load that is freed in the process.
* @return New surface that is prepared for texture loading.
*/
SDL_Surface* gl_prepareSurface( SDL_Surface* surface )
{
SDL_Surface* temp;
int potw, poth;
SDL_Rect rtemp;
#if ! SDL_VERSION_ATLEAST(1,3,0)
Uint32 saved_flags;
#endif /* ! SDL_VERSION_ATLEAST(1,3,0) */
/* Make size power of two. */
potw = gl_pot(surface->w);
poth = gl_pot(surface->h);
if (gl_needPOT() && ((potw != surface->w) || (poth != surface->h))) {
/* we must blit with an SDL_Rect */
rtemp.x = rtemp.y = 0;
rtemp.w = surface->w;
rtemp.h = surface->h;
/* saves alpha */
#if SDL_VERSION_ATLEAST(1,3,0)
SDL_SetSurfaceBlendMode(surface, SDL_BLENDMODE_NONE);
/* create the temp POT surface */
temp = SDL_CreateRGBSurface( 0, potw, poth,
surface->format->BytesPerPixel*8, RGBAMASK );
#else /* SDL_VERSION_ATLEAST(1,3,0) */
saved_flags = surface->flags & (SDL_SRCALPHA | SDL_RLEACCELOK);
if ((saved_flags & SDL_SRCALPHA) == SDL_SRCALPHA) {
SDL_SetAlpha( surface, 0, SDL_ALPHA_OPAQUE );
SDL_SetColorKey( surface, 0, surface->format->colorkey );
}
/* create the temp POT surface */
temp = SDL_CreateRGBSurface( SDL_SRCCOLORKEY,
potw, poth, surface->format->BytesPerPixel*8, RGBAMASK );
#endif /* SDL_VERSION_ATLEAST(1,3,0) */
if (temp == NULL) {
WARN("Unable to create POT surface: %s", SDL_GetError());
return 0;
}
if (SDL_FillRect( temp, NULL,
SDL_MapRGBA(surface->format,0,0,0,SDL_ALPHA_TRANSPARENT))) {
WARN("Unable to fill rect: %s", SDL_GetError());
return 0;
}
/* change the surface to the new blitted one */
SDL_BlitSurface( surface, &rtemp, temp, &rtemp);
SDL_FreeSurface( surface );
surface = temp;
#if ! SDL_VERSION_ATLEAST(1,3,0)
/* set saved alpha */
if ( (saved_flags & SDL_SRCALPHA) == SDL_SRCALPHA )
SDL_SetAlpha( surface, 0, 0 );
#endif /* ! SDL_VERSION_ATLEAST(1,3,0) */
}
return surface;
}
/**
* @brief Checks to see if mipmaps are supported and enabled.
*
* @return 1 if mipmaps are supported and enabled.
*/
int gl_texHasMipmaps (void)
{
return (nglGenerateMipmap != NULL);
}
/**
* @brief Checks to see if texture compression is available and enabled.
*
* @return 1 if texture compression is available and enabled.
*/
int gl_texHasCompress (void)
{
return (nglCompressedTexImage2D != NULL);
}
/**
* @brief Loads a surface into an opengl texture.
*
* @param surface Surface to load into a texture.
* @param flags Flags to use.
* @param[out] rw Real width of the texture.
* @param[out] rh Real height of the texture.
* @return The opengl texture id.
*/
static GLuint gl_loadSurface( SDL_Surface* surface, int *rw, int *rh, unsigned int flags, int freesur )
{
GLuint texture;
GLfloat param;
/* Prepare the surface. */
surface = gl_prepareSurface( surface );
if (rw != NULL)
(*rw) = surface->w;
if (rh != NULL)
(*rh) = surface->h;
/* opengl texture binding */
glGenTextures( 1, &texture ); /* Creates the texture */
glBindTexture( GL_TEXTURE_2D, texture ); /* Loads the texture */
/* Filtering, LINEAR is better for scaling, nearest looks nicer, LINEAR
* also seems to create a bit of artifacts around the edges */
if ((gl_screen.scale != 1.) || (flags & OPENGL_TEX_MIPMAPS)) {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
}
else {
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
}
/* Always wrap just in case. */
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
/* now lead the texture data up */
SDL_LockSurface( surface );
if (gl_texHasCompress()) {
glTexImage2D( GL_TEXTURE_2D, 0, surface->format->BytesPerPixel,
surface->w, surface->h, 0, GL_COMPRESSED_RGBA,
GL_UNSIGNED_BYTE, surface->pixels );
}
else {
glTexImage2D( GL_TEXTURE_2D, 0, surface->format->BytesPerPixel,
surface->w, surface->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, surface->pixels );
}
SDL_UnlockSurface( surface );
/* Create mipmaps. */
if ((flags & OPENGL_TEX_MIPMAPS) && gl_texHasMipmaps()) {
/* Do fancy stuff. */
if (gl_hasExt("GL_EXT_texture_filter_anisotropic")) {
glGetFloatv(GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &param);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY_EXT, param);
}
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 9);
/* Now generate the mipmaps. */
nglGenerateMipmap(GL_TEXTURE_2D);
}
/* cleanup */
if (freesur)
SDL_FreeSurface( surface );
gl_checkErr();
return texture;
}
/**
* @brief Wrapper for gl_loadImagePad that includes transparency mapping.
*
* @param name Name to load with.
* @param surface Surface to load.
* @param rw RWops containing data to hash.
* @param flags Flags to use.
* @param w Non-padded width.
* @param h Non-padded height.
* @param sx X sprites.
* @param sy Y sprites.
* @param freesur Whether or not to free the surface.
* @return The glTexture for surface.
*/
glTexture* gl_loadImagePadTrans( const char *name, SDL_Surface* surface, SDL_RWops *rw,
unsigned int flags, int w, int h, int sx, int sy, int freesur )
{
glTexture *texture;
int i, filesize;
size_t cachesize, pngsize;
uint8_t *trans;
char *cachefile, *data;
char digest[33];
md5_state_t md5;
md5_byte_t *md5val;
if (name != NULL) {
texture = gl_texExists( name );
if (texture != NULL)
return texture;
}
if (flags & OPENGL_TEX_MAPTRANS)
flags ^= OPENGL_TEX_MAPTRANS;
/* Appropriate size for the transparency map, see SDL_MapTrans */
cachesize = gl_transSize(w, h);
cachefile = NULL;
trans = NULL;
if (rw != NULL) {
md5val = malloc(16);
md5_init(&md5);
pngsize = SDL_RWseek( rw, 0, SEEK_END );
SDL_RWseek( rw, 0, SEEK_SET );
data = malloc(pngsize);
if (data == NULL)
WARN("Out of memory!");
else {
SDL_RWread( rw, data, pngsize, 1 );
md5_append( &md5, (md5_byte_t*)data, pngsize );
free(data);
}
md5_finish( &md5, md5val );
for (i=0; i<16; i++)
nsnprintf( &digest[i * 2], 3, "%02x", md5val[i] );
free(md5val);
cachefile = malloc( PATH_MAX );
nsnprintf( cachefile, PATH_MAX, "%scollisions/%s",
nfile_cachePath(), digest );
/* Attempt to find a cached transparency map. */
if (nfile_fileExists(cachefile)) {
trans = (uint8_t*)nfile_readFile( &filesize, cachefile );
/* Consider cached data invalid if the length doesn't match. */
if (trans != NULL && cachesize != (unsigned int)filesize) {
free(trans);
trans = NULL;
}
/* Cached data matches, no need to overwrite. */
else {
free(cachefile);
cachefile = NULL;
}
}
}
else {
/* We could hash raw pixel data here, but that's slower than just
* generating the map from scratch.
*/
WARN("Texture '%s' has no RWops", name);
}
if (trans == NULL) {
SDL_LockSurface(surface);
trans = SDL_MapTrans( surface, w, h );
SDL_UnlockSurface(surface);
if (cachefile != NULL) {
/* Cache newly-generated transparency map. */
nfile_dirMakeExist( "%s/collisions/", nfile_cachePath() );
nfile_writeFile( (char*)trans, cachesize, cachefile );
free(cachefile);
}
}
texture = gl_loadImagePad( name, surface, flags, w, h, sx, sy, freesur );
texture->trans = trans;
return texture;
}
/**
* @brief Loads the already padded SDL_Surface to a glTexture.
*
* @param name Name to load with.
* @param surface Surface to load.
* @param flags Flags to use.
* @param w Non-padded width.
* @param h Non-padded height.
* @param sx X sprites.
* @param sy Y sprites.
* @param freesur Whether or not to free the surface.
* @return The glTexture for surface.
*/
glTexture* gl_loadImagePad( const char *name, SDL_Surface* surface,
unsigned int flags, int w, int h, int sx, int sy, int freesur )
{
glTexture *texture;
int rw, rh;
/* Make sure doesn't already exist. */
if (name != NULL) {
texture = gl_texExists( name );
if (texture != NULL)
return texture;
}
if (flags & OPENGL_TEX_MAPTRANS)
return gl_loadImagePadTrans( name, surface, NULL, flags, w, h,
sx, sy, freesur );
/* set up the texture defaults */
texture = calloc( 1, sizeof(glTexture) );
texture->w = (double) w;
texture->h = (double) h;
texture->sx = (double) sx;
texture->sy = (double) sy;
texture->texture = gl_loadSurface( surface, &rw, &rh, flags, freesur );
texture->rw = (double) rw;
texture->rh = (double) rh;
texture->sw = texture->w / texture->sx;
texture->sh = texture->h / texture->sy;
texture->srw = texture->sw / texture->rw;
texture->srh = texture->sh / texture->rh;
if (name != NULL) {
texture->name = strdup(name);
gl_texAdd( texture );
}
else
texture->name = NULL;
return texture;
}
/**
* @brief Loads the SDL_Surface to a glTexture.
*
* @param surface Surface to load.
* @param flags Flags to use.
* @return The glTexture for surface.
*/
glTexture* gl_loadImage( SDL_Surface* surface, unsigned int flags )
{
return gl_loadImagePad( NULL, surface, flags, surface->w, surface->h, 1, 1, 1 );
}
/**
* @brief Check to see if a texture matching a path already exists.
*
* @param path Path to the texture.
* @return The texture, or NULL if none was found.
*/
static glTexture* gl_texExists( const char* path )
{
glTexList *cur;
/* check to see if it already exists */
if (texture_list != NULL) {
for (cur=texture_list; cur!=NULL; cur=cur->next) {
if (strcmp(path,cur->tex->name)==0) {
cur->used += 1;
return cur->tex;
}
}
}
return NULL;
}
/**
* @brief Adds a texture to the list under the name of path.
*/
static int gl_texAdd( glTexture *tex )
{
glTexList *new, *cur, *last;
/* Create the new node */
new = malloc( sizeof(glTexList) );
new->next = NULL;
new->used = 1;
new->tex = tex;
if (texture_list == NULL) /* special condition - creating new list */
texture_list = new;
else {
for (cur=texture_list; cur!=NULL; cur=cur->next)
last = cur;
last->next = new;
}
return 0;
}
/**
* @brief Loads an image as a texture.
*
* May not necessarily load the image but use one if it's already open.
*
* @param path Image to load.
* @param flags Flags to control image parameters.
* @return Texture loaded from image.
*/
glTexture* gl_newImage( const char* path, const unsigned int flags )
{
glTexture *t;
/* Check if it already exists. */
t = gl_texExists( path );
if (t != NULL)
return t;
/* Load the image */
return gl_loadNewImage( path, flags );
}
/**
* @brief Only loads the image, does not add to stack unlike gl_newImage.
*
* @param path Image to load.
* @param flags Flags to control image parameters.
* @return Texture loaded from image.
*/
static glTexture* gl_loadNewImage( const char* path, const unsigned int flags )
{
glTexture *texture;
SDL_Surface *surface;
SDL_RWops *rw;
npng_t *npng;
png_uint_32 w, h;
int sx, sy;
char *str;
int len;
/* load from packfile */
rw = ndata_rwops( path );
if (rw == NULL) {
WARN("Failed to load surface '%s' from ndata.", path);
return NULL;
}
npng = npng_open( rw );
if (npng == NULL) {
WARN("File '%s' is not a png.", path );
return NULL;
}
npng_dim( npng, &w, &h );
/* Process metadata. */
len = npng_metadata( npng, "sx", &str );
sx = (len > 0) ? atoi(str) : 1;
len = npng_metadata( npng, "sy", &str );
sy = (len > 0) ? atoi(str) : 1;
/* Load surface. */
surface = npng_readSurface( npng, gl_needPOT(), 1 );
npng_close( npng );
if (surface == NULL) {
WARN("'%s' could not be opened", path );
SDL_RWclose( rw );
return NULL;
}
if (flags & OPENGL_TEX_MAPTRANS)
texture = gl_loadImagePadTrans( path, surface, rw, flags, w, h, sx, sy, 1 );
else
texture = gl_loadImagePad( path, surface, flags, w, h, sx, sy, 1 );
SDL_RWclose( rw );
return texture;
}
/**
* @brief Loads the texture immediately, but also sets it as a sprite.
*
* @param path Image to load.
* @param sx Number of X sprites in image.
* @param sy Number of Y sprites in image.
* @param flags Flags to control image parameters.
* @return Texture loaded.
*/
glTexture* gl_newSprite( const char* path, const int sx, const int sy,
const unsigned int flags )
{
glTexture* texture;
texture = gl_newImage( path, flags );
if (texture == NULL)
return NULL;
/* will possibly overwrite an existing textur properties
* so we have to load same texture always the same sprites */
texture->sx = (double) sx;
texture->sy = (double) sy;
texture->sw = texture->w / texture->sx;
texture->sh = texture->h / texture->sy;
texture->srw = texture->sw / texture->rw;
texture->srh = texture->sh / texture->rh;
return texture;
}
/**
* @brief Frees a texture.
*
* @param texture Texture to free.
*/
void gl_freeTexture( glTexture* texture )
{
glTexList *cur, *last;
/* Shouldn't be NULL (won't segfault though) */
if (texture == NULL) {
WARN("Attempting to free NULL texture!");
return;
}
/* see if we can find it in stack */
last = NULL;
for (cur=texture_list; cur!=NULL; cur=cur->next) {
if (cur->tex == texture) { /* found it */
cur->used--;
if (cur->used <= 0) { /* not used anymore */
/* free the texture */
glDeleteTextures( 1, &texture->texture );
if (texture->trans != NULL)
free(texture->trans);
if (texture->name != NULL)
free(texture->name);
free(texture);
/* free the list node */
if (last == NULL) { /* case there's no texture before it */
if (cur->next != NULL)
texture_list = cur->next;
else /* case it's the last texture */
texture_list = NULL;
}
else
last->next = cur->next;
free(cur);
}
return; /* we already found it so we can exit */
}
last = cur;
}
/* Not found */
if (texture->name != NULL) /* Surfaces will have NULL names */
WARN("Attempting to free texture '%s' not found in stack!", texture->name);
/* Free anyways */
glDeleteTextures( 1, &texture->texture );
if (texture->trans != NULL)
free(texture->trans);
if (texture->name != NULL)
free(texture->name);
free(texture);
gl_checkErr();
}
/**
* @brief Duplicates a texture.
*
* @param texture Texture to duplicate.
* @return Duplicate of texture.
*/
glTexture* gl_dupTexture( glTexture *texture )
{
glTexList *cur;
/* No segfaults kthxbye. */
if (texture == NULL)
return NULL;
/* check to see if it already exists */
if (texture_list != NULL) {
for (cur=texture_list; cur!=NULL; cur=cur->next) {
if (texture == cur->tex) {
cur->used += 1;
return cur->tex;
}
}
}
/* Invalid texture. */
WARN("Unable to duplicate texture '%s'.", texture->name);
return NULL;
}
/**
* @brief Checks to see if a pixel is transparent in a texture.
*
* @param t Texture to check for transparency.
* @param x X position of the pixel.
* @param y Y position of the pixel.
* @return 1 if the pixel is transparent or 0 if it isn't.
*/
int gl_isTrans( const glTexture* t, const int x, const int y )
{
int i;
/* Get the position in the sheet. */
i = y*(int)(t->w) + x ;
/* Now we have to pull out the individual bit. */
return !(t->trans[ i/8 ] & (1 << (i%8)));
}
/**
* @brief Sets x and y to be the appropriate sprite for glTexture using dir.
*
* Very slow, try to cache if possible like the pilots do instead of using
* in O(n^2) or worse functions.
*
* @param[out] x X sprite to use.
* @param[out] y Y sprite to use.
* @param t Texture to get sprite from.
* @param dir Direction to get sprite from.
*/
void gl_getSpriteFromDir( int* x, int* y, const glTexture* t, const double dir )
{
int s, sx, sy;
double shard, rdir;
#ifdef DEBUGGING
if ((dir > 2.*M_PI) || (dir < 0.)) {
WARN("Angle not between 0 and 2.*M_PI [%f].", dir);
return;
}
#endif /* DEBUGGING */
/* what each image represents in angle */
shard = 2.*M_PI / (t->sy*t->sx);
/* real dir is slightly moved downwards */
rdir = dir + shard/2.;
/* now calculate the sprite we need */
s = (int)(rdir / shard);
sx = t->sx;
sy = t->sy;
/* makes sure the sprite is "in range" */
if (s > (sy*sx-1))
s = s % (sy*sx);
(*x) = s % sx;
(*y) = s / sx;
}
/**
* @brief Checks to see if OpenGL needs POT textures.
*
* @return 0 if OpenGL doesn't needs POT textures.
*/
int gl_needPOT (void)
{
if (gl_tex_ext_npot && conf.npot)
return 0;
else
return 1;
}
/**
* @brief Initializes the opengl texture subsystem.
*
* @return 0 on success.
*/
int gl_initTextures (void)
{
if (gl_hasVersion(2,0) || gl_hasExt("GL_ARB_texture_non_power_of_two"))
gl_tex_ext_npot = 1;
return 0;
}
/**
* @brief Cleans up the opengl texture subsystem.
*/
void gl_exitTextures (void)
{
glTexList *tex;
/* Make sure there's no texture leak */
if (texture_list != NULL) {
DEBUG("Texture leak detected!");
for (tex=texture_list; tex!=NULL; tex=tex->next)
DEBUG(" '%s' opened %d times", tex->tex->name, tex->used );
}
}
Jump to Line
Something went wrong with that request. Please try again.