feat: parallelise chunkgen, model generation and uploading
This commit is contained in:
parent
dd41aabf0f
commit
c029f40bc7
6 changed files with 150 additions and 46 deletions
|
|
@ -8,6 +8,8 @@ pub fn build(b: *std.Build) void {
|
||||||
const rl = b.dependency("raylib", .{
|
const rl = b.dependency("raylib", .{
|
||||||
.target = target,
|
.target = target,
|
||||||
.optimize = optimize,
|
.optimize = optimize,
|
||||||
|
//.config = "-DSUPPORT_CUSTOM_FRAME_CONTROL",
|
||||||
|
.config = @as([]const u8, "-DSUPPORT_CUSTOM_FRAME_CONTROL"),
|
||||||
});
|
});
|
||||||
break :raylib rl;
|
break :raylib rl;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
2
raylib
2
raylib
|
|
@ -1 +1 @@
|
||||||
Subproject commit 77e626060d3c5419c539edfa4fe4f268f9015ade
|
Subproject commit f740d0941f1bf73077bb369c161bc673e27b735a
|
||||||
127
src/main.zig
127
src/main.zig
|
|
@ -14,6 +14,43 @@ const TILE_TEXTURE_RESOLUTION = 16;
|
||||||
|
|
||||||
const debug = true;
|
const debug = true;
|
||||||
|
|
||||||
|
const FPS_HISTORY_LENGTH = 30;
|
||||||
|
var time: struct {
|
||||||
|
current: f64 = 0.0,
|
||||||
|
previous: f64 = 0.0,
|
||||||
|
target: f64 = 1.0/165.0,
|
||||||
|
frameCounter: u64 = 0,
|
||||||
|
fpsHistory: [FPS_HISTORY_LENGTH]f64 = .{0.0} ** FPS_HISTORY_LENGTH,
|
||||||
|
|
||||||
|
pub fn deltaTime(self: @This()) f64 {
|
||||||
|
return self.current - self.previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fps(self: *@This()) f64 {
|
||||||
|
self.fpsHistory[self.frameCounter % FPS_HISTORY_LENGTH] = 1/(self.current - self.previous);
|
||||||
|
if(self.frameCounter < FPS_HISTORY_LENGTH) return 0;
|
||||||
|
|
||||||
|
var result: f64 = 0.0;
|
||||||
|
for(self.fpsHistory) |entry| {
|
||||||
|
result += entry;
|
||||||
|
}
|
||||||
|
result /= FPS_HISTORY_LENGTH;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(self: *@This()) void {
|
||||||
|
self.previous = self.current;
|
||||||
|
self.current = raylib.GetTime();
|
||||||
|
|
||||||
|
if (self.deltaTime() < self.target){
|
||||||
|
raylib.WaitTime(self.target - self.deltaTime());
|
||||||
|
self.current = raylib.GetTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.frameCounter += 1;
|
||||||
|
}
|
||||||
|
} = .{};
|
||||||
|
|
||||||
pub fn drawCameraPosition(camera: raylib.Camera3D, x: i32, y: i32) !void {
|
pub fn drawCameraPosition(camera: raylib.Camera3D, x: i32, y: i32) !void {
|
||||||
var buf: [256:0]u8 = undefined;
|
var buf: [256:0]u8 = undefined;
|
||||||
|
|
||||||
|
|
@ -25,6 +62,17 @@ pub fn drawCameraPosition(camera: raylib.Camera3D, x: i32, y: i32) !void {
|
||||||
|
|
||||||
raylib.DrawText(slice, x, y, 20, raylib.YELLOW);
|
raylib.DrawText(slice, x, y, 20, raylib.YELLOW);
|
||||||
}
|
}
|
||||||
|
pub fn drawFmtText(comptime fmt: []const u8, args: anytype, x: i32, y: i32) !void {
|
||||||
|
var buf: [256:0]u8 = undefined;
|
||||||
|
|
||||||
|
const slice = try std.fmt.bufPrintZ(
|
||||||
|
&buf,
|
||||||
|
fmt,
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
|
||||||
|
raylib.DrawText(slice, x, y, 20, raylib.BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn moveCamera(camera: *raylib.Camera3D, vec: raylib.Vector3) void {
|
pub fn moveCamera(camera: *raylib.Camera3D, vec: raylib.Vector3) void {
|
||||||
camera.position = v3.add(camera.position, vec);
|
camera.position = v3.add(camera.position, vec);
|
||||||
|
|
@ -34,17 +82,13 @@ pub fn moveCamera(camera: *raylib.Camera3D, vec: raylib.Vector3) void {
|
||||||
pub fn main() !void {
|
pub fn main() !void {
|
||||||
if (!debug) raylib.SetTraceLogLevel(raylib.LOG_ERROR);
|
if (!debug) raylib.SetTraceLogLevel(raylib.LOG_ERROR);
|
||||||
|
|
||||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
var gpa = std.heap.GeneralPurposeAllocator(.{.thread_safe = true}){};
|
||||||
const allocator = gpa.allocator();
|
const allocator = gpa.allocator();
|
||||||
defer {
|
defer {
|
||||||
const status = gpa.deinit();
|
const status = gpa.deinit();
|
||||||
if (status == .leak) std.debug.print("MEMORY LEAK DETECTED!!!!!!!!!!!!!!!!!!!!!!\n", .{}) else std.debug.print("no leaks detected.\n", .{});
|
if (status == .leak) std.debug.print("MEMORY LEAK DETECTED!!!!!!!!!!!!!!!!!!!!!!\n", .{}) else std.debug.print("no leaks detected.\n", .{});
|
||||||
}
|
}
|
||||||
|
|
||||||
var thread_pool: std.Thread.Pool = undefined;
|
|
||||||
try thread_pool.init(.{.allocator = allocator});
|
|
||||||
defer thread_pool.deinit();
|
|
||||||
|
|
||||||
raylib.SetConfigFlags(raylib.FLAG_WINDOW_RESIZABLE | raylib.FLAG_FULLSCREEN_MODE);
|
raylib.SetConfigFlags(raylib.FLAG_WINDOW_RESIZABLE | raylib.FLAG_FULLSCREEN_MODE);
|
||||||
|
|
||||||
const display = raylib.GetCurrentMonitor();
|
const display = raylib.GetCurrentMonitor();
|
||||||
|
|
@ -79,21 +123,26 @@ pub fn main() !void {
|
||||||
defer raylib.UnloadShader(shader);
|
defer raylib.UnloadShader(shader);
|
||||||
raylib.SetShaderValue(shader, raylib.GetShaderLocation(shader, "textureTiling"), &.{ @as(f32, @floatFromInt(tile_columns)), @as(f32, @floatFromInt(tile_rows)) }, raylib.SHADER_UNIFORM_VEC2);
|
raylib.SetShaderValue(shader, raylib.GetShaderLocation(shader, "textureTiling"), &.{ @as(f32, @floatFromInt(tile_columns)), @as(f32, @floatFromInt(tile_rows)) }, raylib.SHADER_UNIFORM_VEC2);
|
||||||
|
|
||||||
var world_state: WorldState = WorldState.init(thread_pool, allocator);
|
var thread_pool: std.Thread.Pool = undefined;
|
||||||
|
try thread_pool.init(.{.allocator = allocator});
|
||||||
|
defer thread_pool.deinit();
|
||||||
|
|
||||||
|
var global_graphics_mutex: std.Thread.Mutex = undefined;
|
||||||
|
|
||||||
|
var world_state: WorldState = WorldState.init(&thread_pool, allocator);
|
||||||
defer world_state.deinit();
|
defer world_state.deinit();
|
||||||
|
|
||||||
for (0..10) |x| for (0..10) |z| {
|
for (0..50) |x| for (0..50) |z| {
|
||||||
_ = try world_state.generateChunk(.{@intCast(x), 0, @intCast(z)});
|
try world_state.queueLoadChunk(.{@intCast(x), 0, @intCast(z)}, tile_rows, tile_columns, texture, ambient_occlusion_texture, shader, &global_graphics_mutex);
|
||||||
_ = try world_state.generateChunkModel(.{@intCast(x), 0, @intCast(z)}, tile_rows, tile_columns, texture, ambient_occlusion_texture, shader);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
while (!raylib.WindowShouldClose()) {
|
while (!raylib.WindowShouldClose()) {
|
||||||
raylib.ClearBackground(raylib.BLACK);
|
raylib.PollInputEvents();
|
||||||
|
|
||||||
const right = v3.neg(v3.nor(v3.cross(camera.up, v3.sub(camera.target, camera.position))));
|
const right = v3.neg(v3.nor(v3.cross(camera.up, v3.sub(camera.target, camera.position))));
|
||||||
const forward = v3.cross(right, v3.neg(camera.up));
|
const forward = v3.cross(right, v3.neg(camera.up));
|
||||||
|
|
||||||
const speed = @as(f32, if (raylib.IsKeyDown(raylib.KEY_LEFT_CONTROL)) 25 else 5) * raylib.GetFrameTime();
|
const speed = @as(f32, if (raylib.IsKeyDown(raylib.KEY_LEFT_CONTROL)) 25 else 5) * time.deltaTime();
|
||||||
var movement = v3.new(0, 0, 0);
|
var movement = v3.new(0, 0, 0);
|
||||||
|
|
||||||
if (raylib.IsKeyDown(raylib.KEY_SPACE)) movement.y += 1;
|
if (raylib.IsKeyDown(raylib.KEY_SPACE)) movement.y += 1;
|
||||||
|
|
@ -104,7 +153,7 @@ pub fn main() !void {
|
||||||
if (raylib.IsKeyDown(raylib.KEY_D)) movement = v3.add(movement, right);
|
if (raylib.IsKeyDown(raylib.KEY_D)) movement = v3.add(movement, right);
|
||||||
if (raylib.IsKeyDown(raylib.KEY_A)) movement = v3.sub(movement, right);
|
if (raylib.IsKeyDown(raylib.KEY_A)) movement = v3.sub(movement, right);
|
||||||
|
|
||||||
moveCamera(&camera, v3.scl(v3.nor(movement), speed));
|
moveCamera(&camera, v3.scl(v3.nor(movement), @floatCast(speed)));
|
||||||
|
|
||||||
const delta = raylib.GetMouseDelta();
|
const delta = raylib.GetMouseDelta();
|
||||||
// on the first mouse movement, for some reason mouse delta is way too large, so we just ignore too large deltas
|
// on the first mouse movement, for some reason mouse delta is way too large, so we just ignore too large deltas
|
||||||
|
|
@ -120,26 +169,48 @@ pub fn main() !void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
raylib.BeginDrawing();
|
|
||||||
defer raylib.EndDrawing();
|
|
||||||
|
|
||||||
{
|
{
|
||||||
raylib.BeginMode3D(camera);
|
global_graphics_mutex.lock();
|
||||||
defer raylib.EndMode3D();
|
defer global_graphics_mutex.unlock();
|
||||||
|
raylib.MakeContextCurrent();
|
||||||
|
defer raylib.DropContextCurrent();
|
||||||
|
|
||||||
world_state.chunks_access_mutex.lock();
|
raylib.ClearBackground(raylib.BLACK);
|
||||||
var chunks_iterator = world_state.chunks.valueIterator();
|
|
||||||
while (chunks_iterator.next()) |entry| {
|
{
|
||||||
if (entry.*.model) |model| {
|
raylib.BeginDrawing();
|
||||||
const model_position = v3.new(@floatFromInt(entry.position[0]*16), @floatFromInt(entry.position[1]*16), @floatFromInt(entry.position[2]*16));
|
defer raylib.EndDrawing();
|
||||||
raylib.DrawChunkModel(model, model_position, 0.5, raylib.WHITE);
|
|
||||||
|
var chunk_count: u32 = 0;
|
||||||
|
var shown_count: u32 = 0;
|
||||||
|
|
||||||
|
{
|
||||||
|
raylib.BeginMode3D(camera);
|
||||||
|
defer raylib.EndMode3D();
|
||||||
|
|
||||||
|
world_state.chunks_access_mutex.lock();
|
||||||
|
defer world_state.chunks_access_mutex.unlock();
|
||||||
|
|
||||||
|
var chunks_iterator = world_state.chunks.valueIterator();
|
||||||
|
while (chunks_iterator.next()) |entry_ptr| {
|
||||||
|
const entry = entry_ptr.*;
|
||||||
|
if (entry.*.model) |model| {
|
||||||
|
const model_position = v3.new(@floatFromInt(entry.position[0]*16), @floatFromInt(entry.position[1]*16), @floatFromInt(entry.position[2]*16));
|
||||||
|
raylib.DrawChunkModel(model, model_position, 0.5, raylib.WHITE);
|
||||||
|
shown_count += 1;
|
||||||
|
}
|
||||||
|
chunk_count += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
world_state.chunks_access_mutex.unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
raylib.DrawFPS(10, 10);
|
try drawFmtText("fps: {d:.0}", .{time.fps()}, 10, 10);
|
||||||
try drawCameraPosition(camera, 10, 30);
|
try drawCameraPosition(camera, 10, 30);
|
||||||
|
try drawFmtText("chunks shown: {}, chunks total: {}", .{shown_count, chunk_count}, 10, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
raylib.SwapScreenBuffer();
|
||||||
|
}
|
||||||
|
time.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -387,10 +387,7 @@ pub const Chunk = struct {
|
||||||
var raw_quads = try scanForRawQuads(chunk);
|
var raw_quads = try scanForRawQuads(chunk);
|
||||||
defer raw_quads.deinit();
|
defer raw_quads.deinit();
|
||||||
|
|
||||||
var mesh = packMeshFromRawQuads(raw_quads, tile_columns, tile_rows);
|
const mesh = packMeshFromRawQuads(raw_quads, tile_columns, tile_rows);
|
||||||
|
|
||||||
raylib.UploadChunkMesh(@ptrCast(&mesh), false);
|
|
||||||
|
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const std = @import("std");
|
||||||
const Thread = std.Thread;
|
const Thread = std.Thread;
|
||||||
const Allocator = std.mem.Allocator;
|
const Allocator = std.mem.Allocator;
|
||||||
const AutoHashMap = std.AutoHashMap;
|
const AutoHashMap = std.AutoHashMap;
|
||||||
|
const ArrayList = std.ArrayList;
|
||||||
|
|
||||||
const Chunk = @import("chunk.zig").Chunk;
|
const Chunk = @import("chunk.zig").Chunk;
|
||||||
const chunk_generators = @import("chunk_generators.zig");
|
const chunk_generators = @import("chunk_generators.zig");
|
||||||
|
|
@ -10,28 +11,29 @@ const raylib_helper = @import("../lib_helpers/raylib_helper.zig");
|
||||||
const raylib = raylib_helper.raylib;
|
const raylib = raylib_helper.raylib;
|
||||||
|
|
||||||
pub const WorldStateError = error{
|
pub const WorldStateError = error{
|
||||||
ChunkNotGeneratedError,
|
ChunkNotGeneratedError,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const ChunkEntry = struct {
|
pub const ChunkEntry = struct {
|
||||||
|
mutex: Thread.Mutex = .{},
|
||||||
chunk: ?Chunk = null,
|
chunk: ?Chunk = null,
|
||||||
model: ?raylib.ChunkModel = null,
|
model: ?raylib.ChunkModel = null,
|
||||||
position: @Vector(3, i64),
|
position: @Vector(3, i64),
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const WorldState = struct {
|
pub const WorldState = struct {
|
||||||
pool: Thread.Pool = undefined,
|
pool: *Thread.Pool = undefined,
|
||||||
allocator: Allocator = undefined,
|
allocator: Allocator = undefined,
|
||||||
|
|
||||||
// TODO: we can do better than a hashmap and a mutex
|
// TODO: we can do better than a hashmap and a mutex
|
||||||
chunks: AutoHashMap(@Vector(3, i64), ChunkEntry) = undefined,
|
chunks: AutoHashMap(@Vector(3, i64), *ChunkEntry) = undefined,
|
||||||
chunks_access_mutex: Thread.Mutex = .{},
|
chunks_access_mutex: Thread.Mutex = .{},
|
||||||
|
|
||||||
pub fn init(global_pool: Thread.Pool, allocator: Allocator) WorldState {
|
pub fn init(global_pool: *Thread.Pool, allocator: Allocator) WorldState {
|
||||||
var world_state: WorldState = undefined;
|
var world_state: WorldState = undefined;
|
||||||
world_state.pool = global_pool;
|
world_state.pool = global_pool;
|
||||||
world_state.allocator = allocator;
|
world_state.allocator = allocator;
|
||||||
world_state.chunks = AutoHashMap(@Vector(3, i64), ChunkEntry).init(allocator);
|
world_state.chunks = AutoHashMap(@Vector(3, i64), *ChunkEntry).init(allocator);
|
||||||
return world_state;
|
return world_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,33 +46,65 @@ pub const WorldState = struct {
|
||||||
if(entry.*.model) |model| {
|
if(entry.*.model) |model| {
|
||||||
raylib.UnloadChunkModel(model);
|
raylib.UnloadChunkModel(model);
|
||||||
}
|
}
|
||||||
|
self.allocator.destroy(entry.*);
|
||||||
}
|
}
|
||||||
self.chunks.deinit();
|
self.chunks.deinit();
|
||||||
|
self.* = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn queueLoadChunk(self: *WorldState, pos: @Vector(3, i64), tile_rows: u32, tile_columns: u32, texture: raylib.Texture, ambient_occlusion_texture: raylib.Texture, shader: raylib.Shader, global_graphics_mutex: *Thread.Mutex) !void {
|
||||||
|
try self.pool.spawn(completeChunk, .{self, pos, tile_rows, tile_columns, texture, ambient_occlusion_texture, shader, global_graphics_mutex});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn completeChunk(self: *WorldState, pos: @Vector(3, i64), tile_rows: u32, tile_columns: u32, texture: raylib.Texture, ambient_occlusion_texture: raylib.Texture, shader: raylib.Shader, global_graphics_mutex: *Thread.Mutex) void {
|
||||||
|
_ = generateChunk(self, pos) catch |err| std.debug.print("error while generating chunk: {}", .{err});
|
||||||
|
_ = generateChunkModel(self, pos, tile_rows, tile_columns, texture, ambient_occlusion_texture, shader, global_graphics_mutex) catch |err| std.debug.print("error while generating chunk model: {}", .{err});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generateChunk(self: *WorldState, pos: @Vector(3, i64)) !Chunk {
|
pub fn generateChunk(self: *WorldState, pos: @Vector(3, i64)) !Chunk {
|
||||||
const chunk = try chunk_generators.createChunk(self.allocator, pos);
|
const chunk = try chunk_generators.createChunk(self.allocator, pos);
|
||||||
|
const entry_ptr = try self.allocator.create(ChunkEntry);
|
||||||
|
entry_ptr.* = .{.chunk = chunk, .position = pos};
|
||||||
self.chunks_access_mutex.lock();
|
self.chunks_access_mutex.lock();
|
||||||
try self.chunks.put(pos, .{.chunk = chunk, .position = pos});
|
try self.chunks.put(pos, entry_ptr);
|
||||||
self.chunks_access_mutex.unlock();
|
self.chunks_access_mutex.unlock();
|
||||||
return chunk;
|
return chunk;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generateChunkModel(self: *WorldState, pos: @Vector(3, i64), tile_rows: u32, tile_columns: u32, texture: raylib.Texture, ambient_occlusion_texture: raylib.Texture, shader: raylib.Shader) !raylib.ChunkModel {
|
pub fn generateChunkModel(self: *WorldState, pos: @Vector(3, i64), tile_rows: u32, tile_columns: u32, texture: raylib.Texture, ambient_occlusion_texture: raylib.Texture, shader: raylib.Shader, global_graphics_mutex: *Thread.Mutex) !raylib.ChunkModel {
|
||||||
self.chunks_access_mutex.lock();
|
self.chunks_access_mutex.lock();
|
||||||
const chunk_entry = (try self.chunks.getOrPutValue(pos, .{.position = pos})).value_ptr;
|
const chunk_entry = blk: {
|
||||||
|
const result = try self.chunks.getOrPut(pos);
|
||||||
|
if(!result.found_existing) {
|
||||||
|
const entry_ptr = try self.allocator.create(ChunkEntry);
|
||||||
|
entry_ptr.* = .{.position = pos};
|
||||||
|
result.value_ptr.* = entry_ptr;
|
||||||
|
}
|
||||||
|
break :blk result.value_ptr.*;
|
||||||
|
};
|
||||||
self.chunks_access_mutex.unlock();
|
self.chunks_access_mutex.unlock();
|
||||||
|
|
||||||
const chunk = chunk_entry.chunk;
|
const chunk = chunk_entry.chunk;
|
||||||
if(chunk == null) return WorldStateError.ChunkNotGeneratedError;
|
if(chunk == null) return WorldStateError.ChunkNotGeneratedError;
|
||||||
|
|
||||||
const model = raylib.LoadChunkModelFromMesh(try chunk.?.createMesh(tile_rows, tile_columns));
|
var mesh = try chunk.?.createMesh(tile_rows, tile_columns);
|
||||||
model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE].texture = texture;
|
const model = blk: {
|
||||||
model.materials[0].shader = shader;
|
global_graphics_mutex.lock();
|
||||||
|
defer global_graphics_mutex.unlock();
|
||||||
|
raylib.MakeContextCurrent();
|
||||||
|
defer raylib.DropContextCurrent();
|
||||||
|
|
||||||
model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1] = raylib.GetShaderLocation(shader, "occlusionMap");
|
raylib.UploadChunkMesh(@ptrCast(&mesh), false);
|
||||||
raylib.SetShaderValueTexture(shader, model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1], ambient_occlusion_texture); model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE+1].texture = ambient_occlusion_texture;
|
|
||||||
|
|
||||||
|
const model = raylib.LoadChunkModelFromMesh(mesh);
|
||||||
|
model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE].texture = texture;
|
||||||
|
model.materials[0].shader = shader;
|
||||||
|
|
||||||
|
model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1] = raylib.GetShaderLocation(shader, "occlusionMap");
|
||||||
|
raylib.SetShaderValueTexture(shader, model.materials[0].shader.locs[raylib.SHADER_LOC_MAP_DIFFUSE+1], ambient_occlusion_texture);
|
||||||
|
model.materials[0].maps[raylib.MATERIAL_MAP_DIFFUSE+1].texture = ambient_occlusion_texture;
|
||||||
|
break :blk model;
|
||||||
|
};
|
||||||
chunk_entry.model = model;
|
chunk_entry.model = model;
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
znoise
2
znoise
|
|
@ -1 +1 @@
|
||||||
Subproject commit 96f9458c2da975a8bf1cdf95e819c7b070965198
|
Subproject commit 76724581c99be0b2f6aa43eb8b63d6f27bada27e
|
||||||
Loading…
Add table
Add a link
Reference in a new issue