Compare commits
3 Commits
Author | SHA1 | Date |
---|---|---|
duarteapcoelho | eea8550238 | |
duarteapcoelho | ae6bbc5861 | |
duarteapcoelho | ffc5f9d651 |
|
@ -7,6 +7,10 @@ sdl/*
|
|||
gint/*
|
||||
!gint/Makefile
|
||||
|
||||
ce/*
|
||||
!ce/makefile
|
||||
!ce/icon.png
|
||||
|
||||
resources/models/models.h
|
||||
resources/models/models.blend1
|
||||
|
||||
|
|
6
Makefile
6
Makefile
|
@ -8,10 +8,13 @@ prizm: prizm/racing.g3a
|
|||
|
||||
gint: gint/racing_singleplayer.g3a
|
||||
|
||||
ce: ce/bin/DEMO.8xp
|
||||
|
||||
clean:
|
||||
make $(MFLAGS) -C sdl/ clean
|
||||
make $(MFLAGS) -C prizm/ clean
|
||||
make $(MFLAGS) -C gint/ clean
|
||||
make $(MFLAGS) -C ce/ clean
|
||||
|
||||
sdl/racing: $(SOURCES)
|
||||
make $(MFLAGS) -C sdl/
|
||||
|
@ -22,6 +25,9 @@ prizm/racing.g3a: $(SOURCES)
|
|||
gint/racing_singleplayer.g3a: $(SOURCES)
|
||||
make $(MFLAGS) -C gint/
|
||||
|
||||
ce/bin/DEMO.8xp: $(SOURCES)
|
||||
make $(MFLAGS) -C ce/
|
||||
|
||||
package: release/racing.zip release/racing.tar.gz release/racing_singleplayer.zip release/racing_singleplayer.tar.gz
|
||||
|
||||
release/racing.zip: prizm/racing.g3a
|
||||
|
|
|
@ -8,7 +8,7 @@ A 3D, multiplayer racing game for casio fx-CG50 calculators
|
|||
## Features
|
||||
- 3D graphics
|
||||
- Simple multiplayer (just connect two calculators)
|
||||
- The multiplayer version runs at about 14 FPS and the singleplayer version runs at 24 FPS
|
||||
- Runs at about 16 FPS normally and 21 FPS overclocked, on the fx-CG50.
|
||||
|
||||
## Controls
|
||||
- Press `up`/`8` to accelerate and `down`/`5` to brake
|
||||
|
@ -47,18 +47,21 @@ This version supports multiplayer, but it's slower than the gint version.
|
|||
This version doesn't support multiplayer, but it runs faster and doesn't have a border.
|
||||
#### Linux
|
||||
- Install gint ([https://gitea.planet-casio.com/Lephenixnoir/gint](https://gitea.planet-casio.com/Lephenixnoir/gint))
|
||||
- Install libprof ([https://gitea.planet-casio.com/Lephenixnoir/libprof](https://gitea.planet-casio.com/Lephenixnoir/libprof))
|
||||
- Run `make gint`
|
||||
|
||||
## Technical information
|
||||
### 3D rendering
|
||||
- All the rendering code is in `src/rasterizer.h` and `src/rasterizer.cpp`
|
||||
- Every triangle is clipped to avoid drawing triangles outside the screen. If a triangle is only partially inside the screen, it's cut in one or two triangles. This doesn't happen with the cones and the car to improve performance.
|
||||
- The triangles are split into two (one with a flat top and another with a flat bottom) and rasterized.
|
||||
- The triangles are rasterized using the scan line algorithm with a depth buffer.
|
||||
- This renderer only supports diffuse directional lighting, because this way there is only one color per triangle, which increases performance.
|
||||
- Because the calculator doesn't have a floating point unit (FPU), everything related to rendering uses fixed point numbers (defined in src/fp.h). This caused some issues related to precision, most of which were solved by checking where the floating point calculations were overflowing.
|
||||
- To improve performance, the cones that are too far away from the camera are replaced with a simpler model and the ones even further away aren't drawn at all.
|
||||
|
||||
#### Potential rendering performance improvements
|
||||
- Use DMA to clear the screen and draw the grass (in progress)
|
||||
- Clip models before clipping triangles
|
||||
|
||||
### Multiplayer
|
||||
All of the multiplayer code is in `src/main.cpp`
|
||||
- When the game starts, a second car is created outside the track.
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 761 B |
|
@ -0,0 +1,17 @@
|
|||
# ----------------------------
|
||||
# Makefile Options
|
||||
# ----------------------------
|
||||
|
||||
NAME = DEMO
|
||||
ICON = icon.png
|
||||
DESCRIPTION = "CE C Toolchain Demo"
|
||||
COMPRESSED = YES
|
||||
ARCHIVED = NO
|
||||
|
||||
SRCDIR = ../src/
|
||||
CFLAGS = -Wall -Wextra -Oz -DCE
|
||||
CXXFLAGS = -Wall -Wextra -Oz -DCE
|
||||
|
||||
# ----------------------------
|
||||
|
||||
include $(shell cedev-config --makefile)
|
|
@ -1,7 +1,7 @@
|
|||
CC = sh-elf-g++
|
||||
CFLAGS += -Wall -Wextra -Ofast -funroll-loops -DGINT
|
||||
CFLAGS += -DFXCG50 -DTARGET_FXCG50 -m4-nofpu -mb -ffreestanding -nostdlib -fstrict-volatile-bitfields
|
||||
LDFLAGS = -m4-nofpu -mb -nostdlib -Wl,--no-warn-rwx-segments -T fxcg50.ld -lgint-cg -lc -lgcc -lgint-cg -lprof-cg
|
||||
LDFLAGS = -m4-nofpu -mb -nostdlib -Wl,--no-warn-rwx-segments -T fxcg50.ld -lgint-cg -lc -lgcc -lgint-cg
|
||||
|
||||
INCLUDES =
|
||||
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
#ifdef CE
|
||||
#include "display.h"
|
||||
#include <ti/screen.h>
|
||||
Color newColor(int r, int g, int b){
|
||||
return {
|
||||
.color = uint8_t((g >> 5) | ((b >> 6) << 3) | ((r >> 5) << 5))
|
||||
};
|
||||
}
|
||||
|
||||
namespace Display {
|
||||
int textHeight = 0; // TODO
|
||||
void init(){
|
||||
gfx_Begin();
|
||||
os_ClrHome();
|
||||
gfx_SetDrawBuffer();
|
||||
}
|
||||
void clear(Color color){
|
||||
fillRect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, color);
|
||||
}
|
||||
void destroy(){
|
||||
gfx_End();
|
||||
}
|
||||
|
||||
void show(){
|
||||
gfx_SwapDraw();
|
||||
}
|
||||
|
||||
int textWidth(const char *text){
|
||||
return 0; // TODO
|
||||
}
|
||||
void drawText(int x, int y, const char *text, Color color){
|
||||
// TODO
|
||||
}
|
||||
};
|
||||
|
||||
#endif
|
|
@ -0,0 +1,21 @@
|
|||
#include <graphx.h>
|
||||
#define DISPLAY_WIDTH GFX_LCD_WIDTH
|
||||
#define DISPLAY_HEIGHT GFX_LCD_HEIGHT
|
||||
|
||||
struct Color {
|
||||
int r;
|
||||
int g;
|
||||
int b;
|
||||
unsigned char color;
|
||||
};
|
||||
|
||||
namespace Display {
|
||||
inline void fillRect(int x, int y, int w, int h, Color color){
|
||||
gfx_SetColor(color.color);
|
||||
gfx_FillRectangle(x, y, w, h);
|
||||
}
|
||||
inline void drawPoint(int x, int y, Color color){
|
||||
gfx_SetColor(color.color);
|
||||
gfx_SetPixel(x, y);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,28 @@
|
|||
#ifdef CE
|
||||
#include "input.h"
|
||||
#include "util.h"
|
||||
#include <keypadc.h>
|
||||
|
||||
namespace Input {
|
||||
uint8_t lastData[7];
|
||||
uint8_t data[7];
|
||||
void init(){
|
||||
}
|
||||
void _updateKeys(){
|
||||
kb_Scan();
|
||||
memcpy(lastData, data, 7*sizeof(uint8_t));
|
||||
for(int i = 0; i < 7; i++)
|
||||
data[i] = kb_Data[i];
|
||||
}
|
||||
bool keyDown(int key){
|
||||
if(key == -1)
|
||||
return false;
|
||||
return data[key / 8] & (1 << (key % 8));
|
||||
}
|
||||
bool keyDownLast(int key){
|
||||
if(key == -1)
|
||||
return false;
|
||||
return lastData[key / 8] & (1 << (key % 8));
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,60 @@
|
|||
#define KEY(x, y) (y-1)*8+x
|
||||
#define KEY_MENU -1
|
||||
#define KEY_EXIT -1
|
||||
#define KEY_EXE KEY(0, 6)
|
||||
#define KEY_AC -1
|
||||
#define KEY_DEL -1
|
||||
#define KEY_OPTN -1
|
||||
#define KEY_VARS -1
|
||||
|
||||
#define KEY_0 KEY(0, 3)
|
||||
#define KEY_1 KEY(1, 3)
|
||||
#define KEY_2 KEY(1, 4)
|
||||
#define KEY_3 KEY(1, 5)
|
||||
#define KEY_4 KEY(2, 3)
|
||||
#define KEY_5 KEY(2, 4)
|
||||
#define KEY_6 KEY(2, 5)
|
||||
#define KEY_7 KEY(3, 3)
|
||||
#define KEY_8 KEY(3, 4)
|
||||
#define KEY_9 KEY(3, 5)
|
||||
|
||||
#define KEY_A -1
|
||||
#define KEY_B -1
|
||||
#define KEY_C -1
|
||||
#define KEY_D -1
|
||||
#define KEY_E -1
|
||||
#define KEY_F -1
|
||||
#define KEY_G -1
|
||||
#define KEY_H -1
|
||||
#define KEY_I -1
|
||||
#define KEY_J -1
|
||||
#define KEY_K -1
|
||||
#define KEY_L -1
|
||||
#define KEY_M -1
|
||||
#define KEY_N -1
|
||||
#define KEY_O -1
|
||||
#define KEY_P -1
|
||||
#define KEY_Q -1
|
||||
#define KEY_R -1
|
||||
#define KEY_S -1
|
||||
#define KEY_T -1
|
||||
#define KEY_U -1
|
||||
#define KEY_V -1
|
||||
#define KEY_W -1
|
||||
#define KEY_X -1
|
||||
#define KEY_Y -1
|
||||
#define KEY_Z -1
|
||||
|
||||
#define KEY_F1 -1
|
||||
#define KEY_F2 -1
|
||||
#define KEY_F3 -1
|
||||
#define KEY_F4 -1
|
||||
#define KEY_F5 -1
|
||||
#define KEY_F6 -1
|
||||
|
||||
#define KEY_UP KEY(3, 7)
|
||||
#define KEY_DOWN KEY(0, 7)
|
||||
#define KEY_LEFT KEY(1, 7)
|
||||
#define KEY_RIGHT KEY(2, 7)
|
||||
|
||||
#define KEY_SHIFT -1
|
23
src/main.cpp
23
src/main.cpp
|
@ -28,13 +28,13 @@ vec3<float> cameraSpeed = {0, 0, 0};
|
|||
float cameraAngle = 0;
|
||||
|
||||
#ifdef GINT
|
||||
static GALIGNED(32) unsigned char depthBuffer[RENDER_WIDTH*RENDER_HEIGHT];
|
||||
static GALIGNED(32) fp depthBuffer[RENDER_WIDTH*RENDER_HEIGHT];
|
||||
#include "models.h"
|
||||
#endif
|
||||
|
||||
int main(){
|
||||
#ifndef GINT
|
||||
unsigned char depthBuffer[RENDER_WIDTH*RENDER_HEIGHT];
|
||||
fp depthBuffer[RENDER_WIDTH*RENDER_HEIGHT];
|
||||
#include "models.h"
|
||||
#endif
|
||||
Rasterizer::depthBuffer = depthBuffer;
|
||||
|
@ -62,12 +62,11 @@ int main(){
|
|||
Track::coneMesh = {22, cone_triangles};
|
||||
Track::simpleConeMesh = {2, simpleConeTriangles};
|
||||
|
||||
Time::init();
|
||||
|
||||
Display::init();
|
||||
Display::clear(newColor(70, 180, 220));
|
||||
Display::show();
|
||||
|
||||
Time::init();
|
||||
Time::update();
|
||||
|
||||
Input::init();
|
||||
|
@ -79,14 +78,14 @@ int main(){
|
|||
{-1, -1, 0},
|
||||
{-1, 1, 0},
|
||||
{1, -1, 0},
|
||||
{0, 0, -1},
|
||||
{0, 1, 0},
|
||||
newColor(255, 255, 0)
|
||||
},
|
||||
{
|
||||
{-1, 1, 0},
|
||||
{1, 1, 0},
|
||||
{1, -1, 0},
|
||||
{0, 0, -1},
|
||||
{0, 1, 0},
|
||||
newColor(255, 255, 0)
|
||||
},
|
||||
};
|
||||
|
@ -139,7 +138,6 @@ int main(){
|
|||
#endif
|
||||
|
||||
#ifdef PRIZM
|
||||
Serial_Close(1);
|
||||
while(Serial_IsOpen() != 1){
|
||||
unsigned char mode[6] = {0, 5, 0, 0, 0, 0}; // 9600 bps 8n1
|
||||
Serial_Open(mode);
|
||||
|
@ -192,9 +190,6 @@ int main(){
|
|||
#ifdef PRIZM
|
||||
while(Input::keyDown(KEY_MENU))
|
||||
Input::update();
|
||||
|
||||
Serial_Close(1);
|
||||
|
||||
timer = Timer_Install(0, []() {
|
||||
Keyboard_PutKeycode(4, 9, 0);
|
||||
Timer_Stop(timer);
|
||||
|
@ -205,14 +200,6 @@ int main(){
|
|||
Bdisp_EnableColor(1);
|
||||
GetKey(&k);
|
||||
|
||||
Serial_Close(1);
|
||||
while(Serial_IsOpen() != 1){
|
||||
unsigned char mode[6] = {0, 5, 0, 0, 0, 0}; // 9600 bps 8n1
|
||||
Serial_Open(mode);
|
||||
}
|
||||
Serial_ClearTX();
|
||||
Serial_ClearRX();
|
||||
|
||||
continue;
|
||||
#endif
|
||||
#ifdef SDL
|
||||
|
|
|
@ -37,7 +37,7 @@ inline int max(int a, int b){
|
|||
namespace Rasterizer {
|
||||
Plane clippingPlanes[5];
|
||||
|
||||
unsigned char *depthBuffer;
|
||||
fp *depthBuffer;
|
||||
|
||||
fp fov_d = 1;
|
||||
|
||||
|
@ -50,21 +50,14 @@ namespace Rasterizer {
|
|||
}
|
||||
|
||||
void reset(){
|
||||
unsigned char value = -1;
|
||||
#if GINT || PRIZM
|
||||
long v = value | (value << 8) | (value << 16) | (value << 24);
|
||||
#endif
|
||||
#if GINT && PIXEL_SIZE == 1
|
||||
unsigned char *depthBuffer_P1 = (unsigned char*) mmu_translate_uram(depthBuffer);
|
||||
cache_ocbp(depthBuffer, RENDER_WIDTH*RENDER_HEIGHT*sizeof(unsigned char));
|
||||
dma_memset(depthBuffer_P1, *((uint32_t*)&v), RENDER_WIDTH*RENDER_HEIGHT*sizeof(unsigned char));
|
||||
#elif PRIZM
|
||||
for(int i = 0; i < RENDER_WIDTH*RENDER_HEIGHT/4; i++){
|
||||
*(((long*)depthBuffer) + i) = v;
|
||||
}
|
||||
fp v = -1;
|
||||
fp *depthBuffer_P1 = (fp*) mmu_translate_uram(depthBuffer);
|
||||
cache_ocbp(depthBuffer, RENDER_WIDTH*RENDER_HEIGHT*sizeof(fp));
|
||||
dma_memset(depthBuffer_P1, *((uint32_t*)&v), RENDER_WIDTH*RENDER_HEIGHT*sizeof(fp));
|
||||
#else
|
||||
for(int i = 0; i < RENDER_WIDTH*RENDER_HEIGHT; i++){
|
||||
depthBuffer[i] = value;
|
||||
depthBuffer[i] = -1;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -105,7 +98,7 @@ namespace Rasterizer {
|
|||
}
|
||||
|
||||
// Draws a triangle which has a horizontal top or bottom
|
||||
inline void _drawFlatSideTriangle(vec3<int> points[3], unsigned char z, Color color, bool useDepth){
|
||||
inline void _drawFlatSideTriangle(vec3<int> points[3], fp z, Color color, bool useDepth){
|
||||
if(points[0].y == points[1].y && points[1].y == points[2].y && points[2].y == points[0].y){
|
||||
return;
|
||||
}
|
||||
|
@ -148,7 +141,7 @@ namespace Rasterizer {
|
|||
#endif
|
||||
|
||||
for(int x = minX; x <= maxX; x++){
|
||||
if(z < depthBuffer[p] || !useDepth){
|
||||
if(z < depthBuffer[p] || depthBuffer[p] == fp(-1) || !useDepth){
|
||||
if(useDepth){
|
||||
depthBuffer[p] = z;
|
||||
}
|
||||
|
@ -184,7 +177,11 @@ namespace Rasterizer {
|
|||
toScreen(p2_d),
|
||||
};
|
||||
|
||||
unsigned char z = (points[0].z + points[1].z + points[2].z) / 3;
|
||||
if(dot(mat4::toMat3(model->viewMatrix) * mat4::toMat3(model->modelMatrix) * triangle.normal, vec3<fp>(0, 0, 1)) > 0){
|
||||
return;
|
||||
}
|
||||
|
||||
fp z = (points[0].z + points[1].z + points[2].z) / 3;
|
||||
|
||||
if(isShaded){
|
||||
fp brightness = dot(mat4::toMat3(model->modelMatrix) * triangle.normal, vec3<fp>(I_SQRT_3, -I_SQRT_3, -I_SQRT_3)) * fp(0.6) + fp(0.4);
|
||||
|
@ -356,10 +353,6 @@ namespace Rasterizer {
|
|||
}
|
||||
|
||||
inline void drawTriangle(Model *model, Triangle triangle, bool useDepth, bool isShaded, bool clipTriangles){
|
||||
if(dot(mat4::toMat3(model->viewMatrix) * mat4::toMat3(model->modelMatrix) * triangle.normal, vec3<fp>(0, 0, 1)) > 0){
|
||||
return;
|
||||
}
|
||||
|
||||
triangle.p0 = model->viewMatrix * model->modelMatrix * triangle.p0;
|
||||
triangle.p1 = model->viewMatrix * model->modelMatrix * triangle.p1;
|
||||
triangle.p2 = model->viewMatrix * model->modelMatrix * triangle.p2;
|
||||
|
@ -369,14 +362,12 @@ namespace Rasterizer {
|
|||
}
|
||||
|
||||
int inside = 5;
|
||||
if(clipTriangles){
|
||||
for(int i = 0; i < 5; i++){
|
||||
if(dot(clippingPlanes[i].n, triangle.p0) + clippingPlanes[i].d < 0
|
||||
|| dot(clippingPlanes[i].n, triangle.p1) + clippingPlanes[i].d < 0
|
||||
|| dot(clippingPlanes[i].n, triangle.p2) + clippingPlanes[i].d < 0){
|
||||
inside--;
|
||||
break;
|
||||
}
|
||||
for(int i = 0; i < 5; i++){
|
||||
if(dot(clippingPlanes[i].n, triangle.p0) + clippingPlanes[i].d < 0
|
||||
|| dot(clippingPlanes[i].n, triangle.p1) + clippingPlanes[i].d < 0
|
||||
|| dot(clippingPlanes[i].n, triangle.p2) + clippingPlanes[i].d < 0){
|
||||
inside--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -398,28 +389,8 @@ Model::Model(){
|
|||
}
|
||||
Model::Model(Mesh mesh){
|
||||
this->mesh = mesh;
|
||||
fp radius2 = 0;
|
||||
for(int i = 0; i < mesh.numTriangles; i++){
|
||||
fp d0 = mesh.triangles[i].p0.length2();
|
||||
fp d1 = mesh.triangles[i].p1.length2();
|
||||
fp d2 = mesh.triangles[i].p2.length2();
|
||||
radius2 = max(radius2, d0);
|
||||
radius2 = max(radius2, d1);
|
||||
radius2 = max(radius2, d2);
|
||||
}
|
||||
float i_radius = _isqrt(radius2);
|
||||
radius = 1.0f/i_radius;
|
||||
}
|
||||
void Model::draw(bool useDepth, bool isShaded, bool clipTriangles){
|
||||
if(!clipTriangles){
|
||||
vec3<fp> center = viewMatrix * modelMatrix * vec3<fp>(0, 0, 0);
|
||||
for(int i = 0; i < 5; i++){
|
||||
if(dot(Rasterizer::clippingPlanes[i].n, center) + Rasterizer::clippingPlanes[i].d < radius){
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(int i = 0; i < mesh.numTriangles; i++){
|
||||
Rasterizer::drawTriangle(this, mesh.triangles[i], useDepth, isShaded, clipTriangles);
|
||||
}
|
||||
|
|
|
@ -26,7 +26,6 @@ struct Mesh {
|
|||
};
|
||||
|
||||
class Model {
|
||||
fp radius;
|
||||
public:
|
||||
Mesh mesh;
|
||||
mat4 modelMatrix;
|
||||
|
@ -39,7 +38,7 @@ public:
|
|||
namespace Rasterizer {
|
||||
void init();
|
||||
void reset();
|
||||
extern unsigned char *depthBuffer;
|
||||
extern fp *depthBuffer;
|
||||
|
||||
extern fp fov_d;
|
||||
void setFOV(int fov);
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
#ifdef CE
|
||||
#include "time.h"
|
||||
#include <sys/rtc.h>
|
||||
|
||||
namespace Time {
|
||||
void init(){
|
||||
rtc_Enable(0);
|
||||
}
|
||||
void update(){
|
||||
const float lastTime = time;
|
||||
time = rtc_Time();
|
||||
delta = time - lastTime;
|
||||
}
|
||||
};
|
||||
#endif
|
|
@ -1,23 +1,12 @@
|
|||
#ifdef GINT
|
||||
#include "time.h"
|
||||
#include <gint/rtc.h>
|
||||
#include <libprof.h>
|
||||
|
||||
namespace Time {
|
||||
prof_t prof;
|
||||
void init(){
|
||||
prof_init();
|
||||
prof = prof_make();
|
||||
}
|
||||
void init(){}
|
||||
void update(){
|
||||
prof_leave(prof);
|
||||
|
||||
const float lastTime = time;
|
||||
time = rtc_ticks();
|
||||
delta = prof_time(prof) / 1000.0f / (1000.0f / 128.0f);
|
||||
|
||||
prof = prof_make();
|
||||
prof_enter(prof);
|
||||
delta = time - lastTime;
|
||||
}
|
||||
};
|
||||
#endif
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
#include <fxcg/rtc.h>
|
||||
|
||||
namespace Time {
|
||||
void init(){
|
||||
}
|
||||
void init(){}
|
||||
void update(){
|
||||
const float lastTime = time;
|
||||
time = RTC_GetTicks();
|
||||
|
|
|
@ -3,8 +3,7 @@
|
|||
#include <SDL2/SDL.h>
|
||||
|
||||
namespace Time {
|
||||
void init(){
|
||||
}
|
||||
void init(){}
|
||||
void update(){
|
||||
const float lastTime = time;
|
||||
time = ((float)(SDL_GetTicks()) / (1000.0/128.0));
|
||||
|
|
|
@ -20,6 +20,12 @@
|
|||
#define srand sys_srand
|
||||
#endif
|
||||
|
||||
#ifdef CE
|
||||
#include "stdio.h"
|
||||
#include "stdlib.h"
|
||||
#include "string.h"
|
||||
#endif
|
||||
|
||||
#ifdef SDL
|
||||
#include "stdio.h"
|
||||
#include "stdlib.h"
|
||||
|
|
Loading…
Reference in New Issue