mirror of
https://github.com/ZDoom/raze-gles.git
synced 2024-12-30 21:31:03 +00:00
847fa48724
In particular this means to remove the option to disable widescreen aspect ratios. The way this was handled makes no sense with the current render backend. The aspect ratio code will have to be redone entirely to properly obey the backend's settings.
560 lines
15 KiB
C++
560 lines
15 KiB
C++
/*
|
|
** glbackend.cpp
|
|
**
|
|
** OpenGL API abstraction
|
|
**
|
|
**---------------------------------------------------------------------------
|
|
** Copyright 2019 Christoph Oelckers
|
|
** All rights reserved.
|
|
**
|
|
** Redistribution and use in source and binary forms, with or without
|
|
** modification, are permitted provided that the following conditions
|
|
** are met:
|
|
**
|
|
** 1. Redistributions of source code must retain the above copyright
|
|
** notice, this list of conditions and the following disclaimer.
|
|
** 2. 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.
|
|
** 3. The name of the author may not be used to endorse or promote products
|
|
** derived from this software without specific prior written permission.
|
|
**
|
|
** THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
|
|
**---------------------------------------------------------------------------
|
|
**
|
|
*/
|
|
#include <memory>
|
|
#include <assert.h>
|
|
#include "glbackend.h"
|
|
#include "textures.h"
|
|
#include "palette.h"
|
|
#include "gamecontrol.h"
|
|
#include "v_2ddrawer.h"
|
|
#include "v_video.h"
|
|
#include "flatvertices.h"
|
|
#include "build.h"
|
|
#include "v_draw.h"
|
|
#include "v_font.h"
|
|
#include "hw_viewpointuniforms.h"
|
|
#include "hw_viewpointbuffer.h"
|
|
#include "hw_renderstate.h"
|
|
#include "hw_cvars.h"
|
|
#include "gamestruct.h"
|
|
|
|
CVAR(Bool, gl_texture, true, 0)
|
|
|
|
F2DDrawer twodpsp;
|
|
static int BufferLock = 0;
|
|
|
|
TArray<VSMatrix> matrixArray;
|
|
|
|
FileReader GetResource(const char* fn)
|
|
{
|
|
auto fr = fileSystem.OpenFileReader(fn);
|
|
if (!fr.isOpen())
|
|
{
|
|
I_Error("Fatal: '%s' not found", fn);
|
|
}
|
|
return fr;
|
|
}
|
|
|
|
GLInstance GLInterface;
|
|
|
|
GLInstance::GLInstance()
|
|
:palmanager(this)
|
|
{
|
|
VSMatrix mat(0);
|
|
matrixArray.Push(mat);
|
|
}
|
|
|
|
//void ImGui_Init_Backend();
|
|
//ImGuiContext* im_ctx;
|
|
TArray<uint8_t> ttf;
|
|
|
|
IHardwareTexture *setpalettelayer(int layer, int translation)
|
|
{
|
|
if (layer == 1)
|
|
return GLInterface.palmanager.GetPalette(GetTranslationType(translation) - Translation_Remap);
|
|
else if (layer == 2)
|
|
return GLInterface.palmanager.GetLookup(GetTranslationIndex(translation));
|
|
else return nullptr;
|
|
}
|
|
|
|
void GLInstance::Init(int ydim)
|
|
{
|
|
FMaterial::SetLayerCallback(setpalettelayer);
|
|
new(&renderState) PolymostRenderState; // reset to defaults.
|
|
}
|
|
|
|
void GLInstance::InitGLState(int fogmode, int multisample)
|
|
{
|
|
// This is a bad place to call this but without deconstructing the entire render loops in all front ends there is no way to have a well defined spot for this stuff.
|
|
// Before doing that the backend needs to work in some fashion, so we have to make sure everything is set up when the first render call is performed.
|
|
screen->BeginFrame();
|
|
}
|
|
|
|
void GLInstance::Deinit()
|
|
{
|
|
palmanager.DeleteAll();
|
|
lastPalswapIndex = -1;
|
|
}
|
|
|
|
void GLInstance::Draw(EDrawType type, size_t start, size_t count)
|
|
{
|
|
assert (BufferLock > 0);
|
|
applyMapFog();
|
|
renderState.vindex = start;
|
|
renderState.vcount = count;
|
|
renderState.primtype = type;
|
|
rendercommands.Push(renderState);
|
|
clearMapFog();
|
|
renderState.StateFlags &= ~(STF_CLEARCOLOR | STF_CLEARDEPTH | STF_VIEWPORTSET | STF_SCISSORSET);
|
|
}
|
|
|
|
void GLInstance::DoDraw()
|
|
{
|
|
GLState lastState;
|
|
|
|
if (rendercommands.Size() > 0)
|
|
{
|
|
lastState.Flags = ~rendercommands[0].StateFlags; // Force ALL flags to be considered 'changed'.
|
|
lastState.DepthFunc = INT_MIN; // Something totally invalid.
|
|
screen->RenderState()->EnableMultisampling(true);
|
|
|
|
for (auto& rs : rendercommands)
|
|
{
|
|
rs.Apply(*screen->RenderState(), lastState);
|
|
screen->RenderState()->Draw(rs.primtype, rs.vindex, rs.vcount);
|
|
}
|
|
renderState.Apply(*screen->RenderState(), lastState); // apply any pending change before returning.
|
|
rendercommands.Clear();
|
|
}
|
|
matrixArray.Resize(1);
|
|
}
|
|
|
|
|
|
int GLInstance::SetMatrix(int num, const VSMatrix *mat)
|
|
{
|
|
int r = renderState.matrixIndex[num];
|
|
renderState.matrixIndex[num] = matrixArray.Size();
|
|
matrixArray.Push(*mat);
|
|
return r;
|
|
}
|
|
|
|
void GLInstance::SetIdentityMatrix(int num)
|
|
{
|
|
renderState.matrixIndex[num] = -1;
|
|
}
|
|
|
|
void GLInstance::SetPalswap(int index)
|
|
{
|
|
renderState.ShadeDiv = lookups.tables[index].ShadeFactor;
|
|
renderState.FogColor = lookups.getFade(index);
|
|
}
|
|
|
|
void PolymostRenderState::Apply(FRenderState& state, GLState& oldState)
|
|
{
|
|
if (Flags & RF_ColorOnly)
|
|
{
|
|
state.EnableTexture(false);
|
|
}
|
|
else
|
|
{
|
|
state.EnableTexture(gl_texture);
|
|
state.SetMaterial(mMaterial.mMaterial, mMaterial.mClampMode, mMaterial.mTranslation, mMaterial.mOverrideShader);
|
|
}
|
|
/* todo: bind indexed textures */
|
|
|
|
state.SetColor(Color[0], Color[1], Color[2], Color[3]);
|
|
if (StateFlags != oldState.Flags)
|
|
{
|
|
state.EnableDepthTest(StateFlags & STF_DEPTHTEST);
|
|
|
|
if ((StateFlags ^ oldState.Flags) & (STF_STENCILTEST | STF_STENCILWRITE))
|
|
{
|
|
if (StateFlags & STF_STENCILWRITE)
|
|
{
|
|
state.EnableStencil(true);
|
|
state.SetEffect(EFF_STENCIL);
|
|
state.SetStencil(0, SOP_Increment, SF_ColorMaskOff);
|
|
}
|
|
else if (StateFlags & STF_STENCILTEST)
|
|
{
|
|
state.EnableStencil(true);
|
|
state.SetEffect(EFF_NONE);
|
|
state.SetStencil(1, SOP_Keep, SF_DepthMaskOff);
|
|
}
|
|
else
|
|
{
|
|
state.EnableStencil(false);
|
|
state.SetEffect(EFF_NONE);
|
|
}
|
|
}
|
|
if ((StateFlags ^ oldState.Flags) & (STF_CULLCW | STF_CULLCCW))
|
|
{
|
|
int cull = Cull_None;
|
|
if (StateFlags & STF_CULLCCW) cull = Cull_CCW;
|
|
else if (StateFlags & STF_CULLCW) cull = Cull_CW;
|
|
state.SetCulling(cull);
|
|
}
|
|
state.SetColorMask(StateFlags & STF_COLORMASK);
|
|
state.SetDepthMask(StateFlags & STF_DEPTHMASK);
|
|
if (StateFlags & (STF_CLEARCOLOR | STF_CLEARDEPTH))
|
|
{
|
|
int clear = 0;
|
|
if (StateFlags & STF_CLEARCOLOR) clear |= CT_Color;
|
|
if (StateFlags & STF_CLEARDEPTH) clear |= CT_Depth;
|
|
state.Clear(clear);
|
|
}
|
|
if (StateFlags & STF_VIEWPORTSET)
|
|
{
|
|
state.SetViewport(vp_x, vp_y, vp_w, vp_h);
|
|
}
|
|
if (StateFlags & STF_SCISSORSET)
|
|
{
|
|
state.SetScissor(sc_x, sc_y, sc_w, sc_h);
|
|
}
|
|
state.SetDepthBias(mBias.mFactor, mBias.mUnits);
|
|
|
|
StateFlags &= ~(STF_CLEARCOLOR | STF_CLEARDEPTH | STF_VIEWPORTSET | STF_SCISSORSET);
|
|
oldState.Flags = StateFlags;
|
|
}
|
|
state.SetRenderStyle(Style);
|
|
if (DepthFunc != oldState.DepthFunc)
|
|
{
|
|
state.SetDepthFunc(DepthFunc);
|
|
oldState.DepthFunc = DepthFunc;
|
|
}
|
|
// Disable brightmaps if non-black fog is used.
|
|
if (!(Flags & RF_FogDisabled) && ShadeDiv >= 1 / 1000.f)
|
|
{
|
|
state.EnableFog(1);
|
|
}
|
|
else state.EnableFog(0);
|
|
state.SetFog((Flags & RF_MapFog) ? PalEntry(0x999999) : FogColor, 21.f); // Fixme: The real density still needs to be implemented. 21 is a reasonable default only.
|
|
state.SetSoftLightLevel(ShadeDiv >= 1 / 1000.f ? 255 - Scale(Shade, 255, numshades) : 255);
|
|
state.SetLightParms(VisFactor, ShadeDiv / (numshades - 2));
|
|
state.SetTextureMode(TextureMode);
|
|
|
|
state.SetNpotEmulation(NPOTEmulation.Y, NPOTEmulation.X);
|
|
state.AlphaFunc(Alpha_Greater, AlphaTest ? AlphaThreshold : -1.f);
|
|
|
|
FVector4 addcol(0, 0, 0, 0);
|
|
FVector4 modcol(fullscreenTint.r / 255.f, fullscreenTint.g / 255.f, fullscreenTint.b / 255.f, 1);
|
|
FVector4 blendcol(0, 0, 0, 0);
|
|
int flags = 0;
|
|
|
|
if (fullscreenTint != 0xffffff) flags |= 16;
|
|
if (hictint_flags != -1)
|
|
{
|
|
flags |= TextureManipulation::ActiveBit;
|
|
if (hictint_flags & TINTF_COLORIZE)
|
|
{
|
|
modcol.X *= hictint.r / 64.f;
|
|
modcol.Y *= hictint.g / 64.f;
|
|
modcol.Z *= hictint.b / 64.f;
|
|
}
|
|
if (hictint_flags & TINTF_GRAYSCALE)
|
|
modcol.W = 1.f;
|
|
|
|
if (hictint_flags & TINTF_INVERT)
|
|
flags |= TextureManipulation::InvertBit;
|
|
|
|
if (hictint_flags & TINTF_BLENDMASK)
|
|
{
|
|
blendcol = modcol; // WTF???, but the tinting code really uses the same color for both!
|
|
flags |= (((hictint_flags & TINTF_BLENDMASK) >> 6) + 1) & TextureManipulation::BlendMask;
|
|
}
|
|
addcol.W = flags;
|
|
}
|
|
state.SetTextureColors(&modcol.X, &addcol.X, &blendcol.X);
|
|
|
|
if (matrixIndex[Matrix_Model] != -1)
|
|
{
|
|
state.EnableModelMatrix(true);
|
|
state.mModelMatrix = matrixArray[matrixIndex[Matrix_Model]];
|
|
}
|
|
else state.EnableModelMatrix(false);
|
|
|
|
memset(matrixIndex, -1, sizeof(matrixIndex));
|
|
}
|
|
|
|
void DoWriteSavePic(FileWriter* file, ESSType ssformat, uint8_t* scr, int width, int height, bool upsidedown)
|
|
{
|
|
int pixelsize = 3;
|
|
int pitch = width * pixelsize;
|
|
if (upsidedown)
|
|
{
|
|
scr += ((height - 1) * width * pixelsize);
|
|
pitch *= -1;
|
|
}
|
|
|
|
M_CreatePNG(file, scr, nullptr, ssformat, width, height, pitch, vid_gamma);
|
|
}
|
|
|
|
//===========================================================================
|
|
//
|
|
// Render the view to a savegame picture
|
|
//
|
|
//===========================================================================
|
|
|
|
void WriteSavePic(FileWriter* file, int width, int height)
|
|
{
|
|
IntRect bounds;
|
|
bounds.left = 0;
|
|
bounds.top = 0;
|
|
bounds.width = width;
|
|
bounds.height = height;
|
|
auto& RenderState = *screen->RenderState();
|
|
|
|
// we must be sure the GPU finished reading from the buffer before we fill it with new data.
|
|
screen->WaitForCommands(false);
|
|
screen->mVertexData->Reset();
|
|
|
|
// Switch to render buffers dimensioned for the savepic
|
|
screen->SetSaveBuffers(true);
|
|
screen->ImageTransitionScene(true);
|
|
|
|
RenderState.SetVertexBuffer(screen->mVertexData);
|
|
screen->mVertexData->Reset();
|
|
//screen->mLights->Clear();
|
|
screen->mViewpoints->Clear();
|
|
|
|
int oldx = xdim;
|
|
int oldy = ydim;
|
|
auto oldwindowxy1 = windowxy1;
|
|
auto oldwindowxy2 = windowxy2;
|
|
|
|
xdim = width;
|
|
ydim = height;
|
|
videoSetViewableArea(0, 0, width - 1, height - 1);
|
|
renderSetAspect(65536, 65536);
|
|
bool didit = gi->GenerateSavePic();
|
|
|
|
xdim = oldx;
|
|
ydim = oldy;
|
|
videoSetViewableArea(oldwindowxy1.x, oldwindowxy1.y, oldwindowxy2.x, oldwindowxy2.y);
|
|
|
|
// The 2D drawers can contain some garbage from the dirty render setup. Get rid of that first.
|
|
twod->Clear();
|
|
twodpsp.Clear();
|
|
|
|
int numpixels = width * height;
|
|
uint8_t* scr = (uint8_t*)M_Malloc(numpixels * 3);
|
|
screen->CopyScreenToBuffer(width, height, scr);
|
|
|
|
DoWriteSavePic(file, SS_RGB, scr, width, height, screen->FlipSavePic());
|
|
M_Free(scr);
|
|
|
|
// Switch back the screen render buffers
|
|
screen->SetViewportRects(nullptr);
|
|
screen->SetSaveBuffers(false);
|
|
}
|
|
|
|
|
|
static HWViewpointUniforms vp;
|
|
|
|
void renderSetProjectionMatrix(const float* p)
|
|
{
|
|
if (p)
|
|
{
|
|
vp.mProjectionMatrix.loadMatrix(p);
|
|
GLInterface.mProjectionM5 = p[5];
|
|
}
|
|
else vp.mProjectionMatrix.loadIdentity();
|
|
}
|
|
|
|
void renderSetViewMatrix(const float* p)
|
|
{
|
|
if (p) vp.mViewMatrix.loadMatrix(p);
|
|
else vp.mViewMatrix.loadIdentity();
|
|
}
|
|
|
|
void renderSetVisibility(float vis)
|
|
{
|
|
vp.mGlobVis = vis;
|
|
}
|
|
|
|
void renderBeginScene()
|
|
{
|
|
if (videoGetRenderMode() < REND_POLYMOST) return;
|
|
assert(BufferLock == 0);
|
|
|
|
vp.mPalLightLevels = numshades | (static_cast<int>(gl_fogmode) << 8) | ((int)5 << 16);
|
|
screen->mViewpoints->SetViewpoint(*screen->RenderState(), &vp);
|
|
|
|
if (BufferLock++ == 0)
|
|
{
|
|
screen->mVertexData->Map();
|
|
}
|
|
}
|
|
|
|
void renderFinishScene()
|
|
{
|
|
if (videoGetRenderMode() < REND_POLYMOST) return;
|
|
assert(BufferLock == 1);
|
|
if (--BufferLock == 0)
|
|
{
|
|
screen->mVertexData->Unmap();
|
|
GLInterface.DoDraw();
|
|
}
|
|
}
|
|
|
|
//==========================================================================
|
|
//
|
|
// DFrameBuffer :: DrawRateStuff
|
|
//
|
|
// Draws the fps counter, dot ticker, and palette debug.
|
|
//
|
|
//==========================================================================
|
|
CVAR(Bool, vid_fps, false, 0)
|
|
|
|
|
|
static FString statFPS()
|
|
{
|
|
static int32_t frameCount;
|
|
static double lastFrameTime;
|
|
static double cumulativeFrameDelay;
|
|
static double lastFPS;
|
|
|
|
FString output;
|
|
|
|
double frameTime = I_msTimeF();
|
|
double frameDelay = frameTime - lastFrameTime;
|
|
cumulativeFrameDelay += frameDelay;
|
|
|
|
frameCount++;
|
|
if (frameDelay >= 0)
|
|
{
|
|
output.AppendFormat("%5.1f fps (%.1f ms)\n", lastFPS, frameDelay);
|
|
|
|
if (cumulativeFrameDelay >= 1000.0)
|
|
{
|
|
lastFPS = 1000. * frameCount / cumulativeFrameDelay;
|
|
frameCount = 0;
|
|
cumulativeFrameDelay = 0.0;
|
|
}
|
|
}
|
|
lastFrameTime = frameTime;
|
|
return output;
|
|
}
|
|
|
|
void DrawRateStuff()
|
|
{
|
|
// Draws frame time and cumulative fps
|
|
if (vid_fps)
|
|
{
|
|
FString fpsbuff = statFPS();
|
|
|
|
int textScale = active_con_scale(twod);
|
|
int rate_x = screen->GetWidth() / textScale - NewConsoleFont->StringWidth(&fpsbuff[0]);
|
|
twod->AddColorOnlyQuad(rate_x * textScale, 0, screen->GetWidth(), NewConsoleFont->GetHeight() * textScale, MAKEARGB(255, 0, 0, 0));
|
|
DrawText(twod, NewConsoleFont, CR_WHITE, rate_x, 0, (char*)&fpsbuff[0],
|
|
DTA_VirtualWidth, screen->GetWidth() / textScale,
|
|
DTA_VirtualHeight, screen->GetHeight() / textScale,
|
|
DTA_KeepRatio, true, TAG_DONE);
|
|
|
|
}
|
|
}
|
|
|
|
int32_t r_scenebrightness = 0;
|
|
|
|
|
|
|
|
void Draw2D(F2DDrawer* drawer, FRenderState& state);
|
|
|
|
void videoShowFrame(int32_t w)
|
|
{
|
|
if (gl_ssao)
|
|
{
|
|
screen->AmbientOccludeScene(GLInterface.GetProjectionM5());
|
|
// To do: the translucent part of the scene should be drawn here, but the render setup in the games is really too broken to do SSAO.
|
|
|
|
//glDrawBuffers(1, buffers);
|
|
}
|
|
|
|
float Brightness = 8.f / (r_scenebrightness + 8.f);
|
|
|
|
screen->PostProcessScene(false, 0, Brightness, []() {
|
|
Draw2D(&twodpsp, *screen->RenderState()); // draws the weapon sprites
|
|
});
|
|
screen->Update();
|
|
screen->mVertexData->Reset();
|
|
// After finishing the frame, reset everything for the next frame. This needs to be done better.
|
|
screen->BeginFrame();
|
|
bool useSSAO = (gl_ssao != 0);
|
|
screen->SetSceneRenderTarget(useSSAO);
|
|
twodpsp.Clear();
|
|
twod->Clear();
|
|
}
|
|
|
|
TMap<int64_t, bool> cachemap;
|
|
|
|
void markTileForPrecache(int tilenum, int palnum)
|
|
{
|
|
int i, j;
|
|
if ((picanm[tilenum].sf & PICANM_ANIMTYPE_MASK) == PICANM_ANIMTYPE_BACK)
|
|
{
|
|
i = tilenum - picanm[tilenum].num;
|
|
j = tilenum;
|
|
}
|
|
else
|
|
{
|
|
i = tilenum;
|
|
j = tilenum + picanm[tilenum].num;
|
|
}
|
|
|
|
for (; i <= j; i++)
|
|
{
|
|
int64_t val = i + (int64_t(palnum) << 32);
|
|
cachemap.Insert(val, true);
|
|
}
|
|
}
|
|
|
|
void polymost_precache(int32_t dapicnum, int32_t dapalnum, int32_t datype);
|
|
|
|
void precacheMarkedTiles()
|
|
{
|
|
decltype(cachemap)::Iterator it(cachemap);
|
|
decltype(cachemap)::Pair* pair;
|
|
while (it.NextPair(pair))
|
|
{
|
|
int dapicnum = pair->Key & 0x7fffffff;
|
|
int dapalnum = pair->Key >> 32;
|
|
polymost_precache(dapicnum, dapalnum, 0);
|
|
}
|
|
}
|
|
|
|
void hud_drawsprite(double sx, double sy, int z, double a, int picnum, int dashade, int dapalnum, int dastat, double alpha)
|
|
{
|
|
double dz = z / 65536.;
|
|
alpha *= (dastat & RS_TRANS1)? glblend[0].def[!!(dastat & RS_TRANS2)].alpha : 1.;
|
|
|
|
DrawTexture(&twodpsp, tileGetTexture(picnum, true), sx, sy,
|
|
DTA_ScaleX, dz, DTA_ScaleY, dz,
|
|
DTA_Color, shadeToLight(dashade),
|
|
DTA_TranslationIndex, TRANSLATION(Translation_Remap + curbasepal, dapalnum),
|
|
DTA_ViewportX, windowxy1.x, DTA_ViewportY, windowxy1.y,
|
|
DTA_ViewportWidth, windowxy2.x - windowxy1.x + 1, DTA_ViewportHeight, windowxy2.y - windowxy1.y + 1,
|
|
DTA_FullscreenScale, (dastat & RS_STRETCH)? FSMode_ScaleToScreen: FSMode_ScaleToHeight, DTA_VirtualWidth, 320, DTA_VirtualHeight, 200,
|
|
DTA_CenterOffsetRel, !(dastat & (RS_TOPLEFT | RS_CENTER)),
|
|
DTA_TopLeft, !!(dastat & RS_TOPLEFT),
|
|
DTA_CenterOffset, !!(dastat & RS_CENTER),
|
|
DTA_FlipX, !!(dastat & RS_XFLIPHUD),
|
|
DTA_FlipY, !!(dastat & RS_YFLIPHUD),
|
|
DTA_Pin, (dastat & RS_ALIGN_R) ? 1 : (dastat & RS_ALIGN_L) ? -1 : 0,
|
|
DTA_Rotate, a * (-360./2048),
|
|
DTA_FlipOffsets, !(dastat & (/*RS_TOPLEFT |*/ RS_CENTER)),
|
|
DTA_Alpha, alpha,
|
|
TAG_DONE);
|
|
}
|
|
|