|
1 /* -*- Mode: c++; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40; -*- */ |
|
2 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
3 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
5 |
|
6 #include "GLUploadHelpers.h" |
|
7 |
|
8 #include "GLContext.h" |
|
9 #include "mozilla/gfx/2D.h" |
|
10 #include "mozilla/gfx/Tools.h" // For BytesPerPixel |
|
11 #include "nsRegion.h" |
|
12 |
|
13 namespace mozilla { |
|
14 |
|
15 using namespace gfx; |
|
16 |
|
17 namespace gl { |
|
18 |
|
19 /* These two techniques are suggested by "Bit Twiddling Hacks" |
|
20 */ |
|
21 |
|
22 /** |
|
23 * Returns true if |aNumber| is a power of two |
|
24 * 0 is incorreclty considered a power of two |
|
25 */ |
|
26 static bool |
|
27 IsPowerOfTwo(int aNumber) |
|
28 { |
|
29 return (aNumber & (aNumber - 1)) == 0; |
|
30 } |
|
31 |
|
32 /** |
|
33 * Returns the first integer greater than |aNumber| which is a power of two |
|
34 * Undefined for |aNumber| < 0 |
|
35 */ |
|
36 static int |
|
37 NextPowerOfTwo(int aNumber) |
|
38 { |
|
39 #if defined(__arm__) |
|
40 return 1 << (32 - __builtin_clz(aNumber - 1)); |
|
41 #else |
|
42 --aNumber; |
|
43 aNumber |= aNumber >> 1; |
|
44 aNumber |= aNumber >> 2; |
|
45 aNumber |= aNumber >> 4; |
|
46 aNumber |= aNumber >> 8; |
|
47 aNumber |= aNumber >> 16; |
|
48 return ++aNumber; |
|
49 #endif |
|
50 } |
|
51 |
|
52 static unsigned int |
|
53 DataOffset(const nsIntPoint &aPoint, int32_t aStride, SurfaceFormat aFormat) |
|
54 { |
|
55 unsigned int data = aPoint.y * aStride; |
|
56 data += aPoint.x * BytesPerPixel(aFormat); |
|
57 return data; |
|
58 } |
|
59 |
|
60 static GLint GetAddressAlignment(ptrdiff_t aAddress) |
|
61 { |
|
62 if (!(aAddress & 0x7)) { |
|
63 return 8; |
|
64 } else if (!(aAddress & 0x3)) { |
|
65 return 4; |
|
66 } else if (!(aAddress & 0x1)) { |
|
67 return 2; |
|
68 } else { |
|
69 return 1; |
|
70 } |
|
71 } |
|
72 |
|
73 // Take texture data in a given buffer and copy it into a larger buffer, |
|
74 // padding out the edge pixels for filtering if necessary |
|
75 static void |
|
76 CopyAndPadTextureData(const GLvoid* srcBuffer, |
|
77 GLvoid* dstBuffer, |
|
78 GLsizei srcWidth, GLsizei srcHeight, |
|
79 GLsizei dstWidth, GLsizei dstHeight, |
|
80 GLsizei stride, GLint pixelsize) |
|
81 { |
|
82 unsigned char *rowDest = static_cast<unsigned char*>(dstBuffer); |
|
83 const unsigned char *source = static_cast<const unsigned char*>(srcBuffer); |
|
84 |
|
85 for (GLsizei h = 0; h < srcHeight; ++h) { |
|
86 memcpy(rowDest, source, srcWidth * pixelsize); |
|
87 rowDest += dstWidth * pixelsize; |
|
88 source += stride; |
|
89 } |
|
90 |
|
91 GLsizei padHeight = srcHeight; |
|
92 |
|
93 // Pad out an extra row of pixels so that edge filtering doesn't use garbage data |
|
94 if (dstHeight > srcHeight) { |
|
95 memcpy(rowDest, source - stride, srcWidth * pixelsize); |
|
96 padHeight++; |
|
97 } |
|
98 |
|
99 // Pad out an extra column of pixels |
|
100 if (dstWidth > srcWidth) { |
|
101 rowDest = static_cast<unsigned char*>(dstBuffer) + srcWidth * pixelsize; |
|
102 for (GLsizei h = 0; h < padHeight; ++h) { |
|
103 memcpy(rowDest, rowDest - pixelsize, pixelsize); |
|
104 rowDest += dstWidth * pixelsize; |
|
105 } |
|
106 } |
|
107 } |
|
108 |
|
109 // In both of these cases (for the Adreno at least) it is impossible |
|
110 // to determine good or bad driver versions for POT texture uploads, |
|
111 // so blacklist them all. Newer drivers use a different rendering |
|
112 // string in the form "Adreno (TM) 200" and the drivers we've seen so |
|
113 // far work fine with NPOT textures, so don't blacklist those until we |
|
114 // have evidence of any problems with them. |
|
115 bool |
|
116 CanUploadSubTextures(GLContext* gl) |
|
117 { |
|
118 if (!gl->WorkAroundDriverBugs()) |
|
119 return true; |
|
120 |
|
121 // There are certain GPUs that we don't want to use glTexSubImage2D on |
|
122 // because that function can be very slow and/or buggy |
|
123 if (gl->Renderer() == GLRenderer::Adreno200 || |
|
124 gl->Renderer() == GLRenderer::Adreno205) |
|
125 { |
|
126 return false; |
|
127 } |
|
128 |
|
129 // On PowerVR glTexSubImage does a readback, so it will be slower |
|
130 // than just doing a glTexImage2D() directly. i.e. 26ms vs 10ms |
|
131 if (gl->Renderer() == GLRenderer::SGX540 || |
|
132 gl->Renderer() == GLRenderer::SGX530) |
|
133 { |
|
134 return false; |
|
135 } |
|
136 |
|
137 return true; |
|
138 } |
|
139 |
|
140 static void |
|
141 TexSubImage2DWithUnpackSubimageGLES(GLContext* gl, |
|
142 GLenum target, GLint level, |
|
143 GLint xoffset, GLint yoffset, |
|
144 GLsizei width, GLsizei height, |
|
145 GLsizei stride, GLint pixelsize, |
|
146 GLenum format, GLenum type, |
|
147 const GLvoid* pixels) |
|
148 { |
|
149 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
150 std::min(GetAddressAlignment((ptrdiff_t)pixels), |
|
151 GetAddressAlignment((ptrdiff_t)stride))); |
|
152 // When using GL_UNPACK_ROW_LENGTH, we need to work around a Tegra |
|
153 // driver crash where the driver apparently tries to read |
|
154 // (stride - width * pixelsize) bytes past the end of the last input |
|
155 // row. We only upload the first height-1 rows using GL_UNPACK_ROW_LENGTH, |
|
156 // and then we upload the final row separately. See bug 697990. |
|
157 int rowLength = stride/pixelsize; |
|
158 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, rowLength); |
|
159 gl->fTexSubImage2D(target, |
|
160 level, |
|
161 xoffset, |
|
162 yoffset, |
|
163 width, |
|
164 height-1, |
|
165 format, |
|
166 type, |
|
167 pixels); |
|
168 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, 0); |
|
169 gl->fTexSubImage2D(target, |
|
170 level, |
|
171 xoffset, |
|
172 yoffset+height-1, |
|
173 width, |
|
174 1, |
|
175 format, |
|
176 type, |
|
177 (const unsigned char *)pixels+(height-1)*stride); |
|
178 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
179 } |
|
180 |
|
181 static void |
|
182 TexSubImage2DWithoutUnpackSubimage(GLContext* gl, |
|
183 GLenum target, GLint level, |
|
184 GLint xoffset, GLint yoffset, |
|
185 GLsizei width, GLsizei height, |
|
186 GLsizei stride, GLint pixelsize, |
|
187 GLenum format, GLenum type, |
|
188 const GLvoid* pixels) |
|
189 { |
|
190 // Not using the whole row of texture data and GL_UNPACK_ROW_LENGTH |
|
191 // isn't supported. We make a copy of the texture data we're using, |
|
192 // such that we're using the whole row of data in the copy. This turns |
|
193 // out to be more efficient than uploading row-by-row; see bug 698197. |
|
194 unsigned char *newPixels = new unsigned char[width*height*pixelsize]; |
|
195 unsigned char *rowDest = newPixels; |
|
196 const unsigned char *rowSource = (const unsigned char *)pixels; |
|
197 for (int h = 0; h < height; h++) { |
|
198 memcpy(rowDest, rowSource, width*pixelsize); |
|
199 rowDest += width*pixelsize; |
|
200 rowSource += stride; |
|
201 } |
|
202 |
|
203 stride = width*pixelsize; |
|
204 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
205 std::min(GetAddressAlignment((ptrdiff_t)newPixels), |
|
206 GetAddressAlignment((ptrdiff_t)stride))); |
|
207 gl->fTexSubImage2D(target, |
|
208 level, |
|
209 xoffset, |
|
210 yoffset, |
|
211 width, |
|
212 height, |
|
213 format, |
|
214 type, |
|
215 newPixels); |
|
216 delete [] newPixels; |
|
217 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
218 } |
|
219 |
|
220 static void |
|
221 TexSubImage2DHelper(GLContext *gl, |
|
222 GLenum target, GLint level, |
|
223 GLint xoffset, GLint yoffset, |
|
224 GLsizei width, GLsizei height, GLsizei stride, |
|
225 GLint pixelsize, GLenum format, |
|
226 GLenum type, const GLvoid* pixels) |
|
227 { |
|
228 if (gl->IsGLES()) { |
|
229 if (stride == width * pixelsize) { |
|
230 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
231 std::min(GetAddressAlignment((ptrdiff_t)pixels), |
|
232 GetAddressAlignment((ptrdiff_t)stride))); |
|
233 gl->fTexSubImage2D(target, |
|
234 level, |
|
235 xoffset, |
|
236 yoffset, |
|
237 width, |
|
238 height, |
|
239 format, |
|
240 type, |
|
241 pixels); |
|
242 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
243 } else if (gl->IsExtensionSupported(GLContext::EXT_unpack_subimage)) { |
|
244 TexSubImage2DWithUnpackSubimageGLES(gl, target, level, xoffset, yoffset, |
|
245 width, height, stride, |
|
246 pixelsize, format, type, pixels); |
|
247 |
|
248 } else { |
|
249 TexSubImage2DWithoutUnpackSubimage(gl, target, level, xoffset, yoffset, |
|
250 width, height, stride, |
|
251 pixelsize, format, type, pixels); |
|
252 } |
|
253 } else { |
|
254 // desktop GL (non-ES) path |
|
255 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
256 std::min(GetAddressAlignment((ptrdiff_t)pixels), |
|
257 GetAddressAlignment((ptrdiff_t)stride))); |
|
258 int rowLength = stride/pixelsize; |
|
259 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, rowLength); |
|
260 gl->fTexSubImage2D(target, |
|
261 level, |
|
262 xoffset, |
|
263 yoffset, |
|
264 width, |
|
265 height, |
|
266 format, |
|
267 type, |
|
268 pixels); |
|
269 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, 0); |
|
270 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
271 } |
|
272 } |
|
273 |
|
274 static void |
|
275 TexImage2DHelper(GLContext *gl, |
|
276 GLenum target, GLint level, GLint internalformat, |
|
277 GLsizei width, GLsizei height, GLsizei stride, |
|
278 GLint pixelsize, GLint border, GLenum format, |
|
279 GLenum type, const GLvoid *pixels) |
|
280 { |
|
281 if (gl->IsGLES()) { |
|
282 |
|
283 NS_ASSERTION(format == (GLenum)internalformat, |
|
284 "format and internalformat not the same for glTexImage2D on GLES2"); |
|
285 |
|
286 if (!CanUploadNonPowerOfTwo(gl) |
|
287 && (stride != width * pixelsize |
|
288 || !IsPowerOfTwo(width) |
|
289 || !IsPowerOfTwo(height))) { |
|
290 |
|
291 // Pad out texture width and height to the next power of two |
|
292 // as we don't support/want non power of two texture uploads |
|
293 GLsizei paddedWidth = NextPowerOfTwo(width); |
|
294 GLsizei paddedHeight = NextPowerOfTwo(height); |
|
295 |
|
296 GLvoid* paddedPixels = new unsigned char[paddedWidth * paddedHeight * pixelsize]; |
|
297 |
|
298 // Pad out texture data to be in a POT sized buffer for uploading to |
|
299 // a POT sized texture |
|
300 CopyAndPadTextureData(pixels, paddedPixels, width, height, |
|
301 paddedWidth, paddedHeight, stride, pixelsize); |
|
302 |
|
303 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
304 std::min(GetAddressAlignment((ptrdiff_t)paddedPixels), |
|
305 GetAddressAlignment((ptrdiff_t)paddedWidth * pixelsize))); |
|
306 gl->fTexImage2D(target, |
|
307 border, |
|
308 internalformat, |
|
309 paddedWidth, |
|
310 paddedHeight, |
|
311 border, |
|
312 format, |
|
313 type, |
|
314 paddedPixels); |
|
315 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
316 |
|
317 delete[] static_cast<unsigned char*>(paddedPixels); |
|
318 return; |
|
319 } |
|
320 |
|
321 if (stride == width * pixelsize) { |
|
322 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
323 std::min(GetAddressAlignment((ptrdiff_t)pixels), |
|
324 GetAddressAlignment((ptrdiff_t)stride))); |
|
325 gl->fTexImage2D(target, |
|
326 border, |
|
327 internalformat, |
|
328 width, |
|
329 height, |
|
330 border, |
|
331 format, |
|
332 type, |
|
333 pixels); |
|
334 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
335 } else { |
|
336 // Use GLES-specific workarounds for GL_UNPACK_ROW_LENGTH; these are |
|
337 // implemented in TexSubImage2D. |
|
338 gl->fTexImage2D(target, |
|
339 border, |
|
340 internalformat, |
|
341 width, |
|
342 height, |
|
343 border, |
|
344 format, |
|
345 type, |
|
346 nullptr); |
|
347 TexSubImage2DHelper(gl, |
|
348 target, |
|
349 level, |
|
350 0, |
|
351 0, |
|
352 width, |
|
353 height, |
|
354 stride, |
|
355 pixelsize, |
|
356 format, |
|
357 type, |
|
358 pixels); |
|
359 } |
|
360 } else { |
|
361 // desktop GL (non-ES) path |
|
362 |
|
363 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, |
|
364 std::min(GetAddressAlignment((ptrdiff_t)pixels), |
|
365 GetAddressAlignment((ptrdiff_t)stride))); |
|
366 int rowLength = stride/pixelsize; |
|
367 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, rowLength); |
|
368 gl->fTexImage2D(target, |
|
369 level, |
|
370 internalformat, |
|
371 width, |
|
372 height, |
|
373 border, |
|
374 format, |
|
375 type, |
|
376 pixels); |
|
377 gl->fPixelStorei(LOCAL_GL_UNPACK_ROW_LENGTH, 0); |
|
378 gl->fPixelStorei(LOCAL_GL_UNPACK_ALIGNMENT, 4); |
|
379 } |
|
380 } |
|
381 |
|
382 SurfaceFormat |
|
383 UploadImageDataToTexture(GLContext* gl, |
|
384 unsigned char* aData, |
|
385 int32_t aStride, |
|
386 SurfaceFormat aFormat, |
|
387 const nsIntRegion& aDstRegion, |
|
388 GLuint& aTexture, |
|
389 bool aOverwrite, |
|
390 bool aPixelBuffer, |
|
391 GLenum aTextureUnit, |
|
392 GLenum aTextureTarget) |
|
393 { |
|
394 bool textureInited = aOverwrite ? false : true; |
|
395 gl->MakeCurrent(); |
|
396 gl->fActiveTexture(aTextureUnit); |
|
397 |
|
398 if (!aTexture) { |
|
399 gl->fGenTextures(1, &aTexture); |
|
400 gl->fBindTexture(aTextureTarget, aTexture); |
|
401 gl->fTexParameteri(aTextureTarget, |
|
402 LOCAL_GL_TEXTURE_MIN_FILTER, |
|
403 LOCAL_GL_LINEAR); |
|
404 gl->fTexParameteri(aTextureTarget, |
|
405 LOCAL_GL_TEXTURE_MAG_FILTER, |
|
406 LOCAL_GL_LINEAR); |
|
407 gl->fTexParameteri(aTextureTarget, |
|
408 LOCAL_GL_TEXTURE_WRAP_S, |
|
409 LOCAL_GL_CLAMP_TO_EDGE); |
|
410 gl->fTexParameteri(aTextureTarget, |
|
411 LOCAL_GL_TEXTURE_WRAP_T, |
|
412 LOCAL_GL_CLAMP_TO_EDGE); |
|
413 textureInited = false; |
|
414 } else { |
|
415 gl->fBindTexture(aTextureTarget, aTexture); |
|
416 } |
|
417 |
|
418 nsIntRegion paintRegion; |
|
419 if (!textureInited) { |
|
420 paintRegion = nsIntRegion(aDstRegion.GetBounds()); |
|
421 } else { |
|
422 paintRegion = aDstRegion; |
|
423 } |
|
424 |
|
425 GLenum format = 0; |
|
426 GLenum internalFormat = 0; |
|
427 GLenum type = 0; |
|
428 int32_t pixelSize = BytesPerPixel(aFormat); |
|
429 SurfaceFormat surfaceFormat = gfx::SurfaceFormat::UNKNOWN; |
|
430 |
|
431 MOZ_ASSERT(gl->GetPreferredARGB32Format() == LOCAL_GL_BGRA || |
|
432 gl->GetPreferredARGB32Format() == LOCAL_GL_RGBA); |
|
433 switch (aFormat) { |
|
434 case SurfaceFormat::B8G8R8A8: |
|
435 if (gl->GetPreferredARGB32Format() == LOCAL_GL_BGRA) { |
|
436 format = LOCAL_GL_BGRA; |
|
437 surfaceFormat = SurfaceFormat::R8G8B8A8; |
|
438 type = LOCAL_GL_UNSIGNED_INT_8_8_8_8_REV; |
|
439 } else { |
|
440 format = LOCAL_GL_RGBA; |
|
441 surfaceFormat = SurfaceFormat::B8G8R8A8; |
|
442 type = LOCAL_GL_UNSIGNED_BYTE; |
|
443 } |
|
444 internalFormat = LOCAL_GL_RGBA; |
|
445 break; |
|
446 case SurfaceFormat::B8G8R8X8: |
|
447 // Treat BGRX surfaces as BGRA except for the surface |
|
448 // format used. |
|
449 if (gl->GetPreferredARGB32Format() == LOCAL_GL_BGRA) { |
|
450 format = LOCAL_GL_BGRA; |
|
451 surfaceFormat = SurfaceFormat::R8G8B8X8; |
|
452 type = LOCAL_GL_UNSIGNED_INT_8_8_8_8_REV; |
|
453 } else { |
|
454 format = LOCAL_GL_RGBA; |
|
455 surfaceFormat = SurfaceFormat::B8G8R8X8; |
|
456 type = LOCAL_GL_UNSIGNED_BYTE; |
|
457 } |
|
458 internalFormat = LOCAL_GL_RGBA; |
|
459 break; |
|
460 case SurfaceFormat::R5G6B5: |
|
461 internalFormat = format = LOCAL_GL_RGB; |
|
462 type = LOCAL_GL_UNSIGNED_SHORT_5_6_5; |
|
463 surfaceFormat = SurfaceFormat::R5G6B5; |
|
464 break; |
|
465 case SurfaceFormat::A8: |
|
466 internalFormat = format = LOCAL_GL_LUMINANCE; |
|
467 type = LOCAL_GL_UNSIGNED_BYTE; |
|
468 // We don't have a specific luminance shader |
|
469 surfaceFormat = SurfaceFormat::A8; |
|
470 break; |
|
471 default: |
|
472 NS_ASSERTION(false, "Unhandled image surface format!"); |
|
473 } |
|
474 |
|
475 nsIntRegionRectIterator iter(paintRegion); |
|
476 const nsIntRect *iterRect; |
|
477 |
|
478 // Top left point of the region's bounding rectangle. |
|
479 nsIntPoint topLeft = paintRegion.GetBounds().TopLeft(); |
|
480 |
|
481 while ((iterRect = iter.Next())) { |
|
482 // The inital data pointer is at the top left point of the region's |
|
483 // bounding rectangle. We need to find the offset of this rect |
|
484 // within the region and adjust the data pointer accordingly. |
|
485 unsigned char *rectData = |
|
486 aData + DataOffset(iterRect->TopLeft() - topLeft, aStride, aFormat); |
|
487 |
|
488 NS_ASSERTION(textureInited || (iterRect->x == 0 && iterRect->y == 0), |
|
489 "Must be uploading to the origin when we don't have an existing texture"); |
|
490 |
|
491 if (textureInited && CanUploadSubTextures(gl)) { |
|
492 TexSubImage2DHelper(gl, |
|
493 aTextureTarget, |
|
494 0, |
|
495 iterRect->x, |
|
496 iterRect->y, |
|
497 iterRect->width, |
|
498 iterRect->height, |
|
499 aStride, |
|
500 pixelSize, |
|
501 format, |
|
502 type, |
|
503 rectData); |
|
504 } else { |
|
505 TexImage2DHelper(gl, |
|
506 aTextureTarget, |
|
507 0, |
|
508 internalFormat, |
|
509 iterRect->width, |
|
510 iterRect->height, |
|
511 aStride, |
|
512 pixelSize, |
|
513 0, |
|
514 format, |
|
515 type, |
|
516 rectData); |
|
517 } |
|
518 |
|
519 } |
|
520 |
|
521 return surfaceFormat; |
|
522 } |
|
523 |
|
524 SurfaceFormat |
|
525 UploadSurfaceToTexture(GLContext* gl, |
|
526 DataSourceSurface *aSurface, |
|
527 const nsIntRegion& aDstRegion, |
|
528 GLuint& aTexture, |
|
529 bool aOverwrite, |
|
530 const nsIntPoint& aSrcPoint, |
|
531 bool aPixelBuffer, |
|
532 GLenum aTextureUnit, |
|
533 GLenum aTextureTarget) |
|
534 { |
|
535 unsigned char* data = aPixelBuffer ? nullptr : aSurface->GetData(); |
|
536 int32_t stride = aSurface->Stride(); |
|
537 SurfaceFormat format = aSurface->GetFormat(); |
|
538 data += DataOffset(aSrcPoint, stride, format); |
|
539 return UploadImageDataToTexture(gl, data, stride, format, |
|
540 aDstRegion, aTexture, aOverwrite, |
|
541 aPixelBuffer, aTextureUnit, |
|
542 aTextureTarget); |
|
543 } |
|
544 |
|
545 bool |
|
546 CanUploadNonPowerOfTwo(GLContext* gl) |
|
547 { |
|
548 if (!gl->WorkAroundDriverBugs()) |
|
549 return true; |
|
550 |
|
551 // Some GPUs driver crash when uploading non power of two 565 textures. |
|
552 return gl->Renderer() != GLRenderer::Adreno200 && |
|
553 gl->Renderer() != GLRenderer::Adreno205; |
|
554 } |
|
555 |
|
556 } |
|
557 } |