Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6d7e9e3
Merge branch 'master' into esefeks
RanPix Mar 22, 2026
f6596ce
play sounds and load them from assets
RanPix Mar 22, 2026
4023a02
convert all bad ogg files to good ogg files and fix memory leak
RanPix Mar 23, 2026
ba74fc8
rename sound setting to audio, add sound volume slider, play multiple…
RanPix Mar 23, 2026
cae0ac4
fix crash while playing multiple sounds
RanPix Mar 23, 2026
986af32
add active sound count to debug menu
RanPix Mar 23, 2026
d364d90
play sound only on actual jump
RanPix Mar 23, 2026
00fa770
fix crash when playing alot of sounds
RanPix Mar 23, 2026
369f237
fixed sound clicking and add volume control for sound type
RanPix Mar 23, 2026
b082e90
oopsie hardcoded path
RanPix Mar 23, 2026
66ea594
change sounds and tweak volume
RanPix Mar 23, 2026
59a71a6
add mono channel support
RanPix Mar 23, 2026
a819070
everything works
RanPix Mar 23, 2026
090cdde
Merge remote-tracking branch 'upstream/master' into esefeks
RanPix Apr 4, 2026
40e76aa
Merge remote-tracking branch 'upstream/master' into esefeks
RanPix Apr 5, 2026
a469cf3
Merge remote-tracking branch 'upstream/master' into esefeks
RanPix Apr 5, 2026
d4ad470
Merge branch 'master' into esefeks
RanPix Jun 21, 2026
4449ef9
back to master
RanPix Jun 21, 2026
7f154fc
cleanup and reorder
RanPix Jun 21, 2026
d7e745b
rename
RanPix Jun 21, 2026
7d8ed9b
add spatial sound
RanPix Jun 21, 2026
731974b
deinit sound map properly
RanPix Jun 24, 2026
70b9356
Merge remote-tracking branch 'origin/master' into esefeks
RanPix Jun 24, 2026
eb74150
improve error message
RanPix Jun 24, 2026
be2f60c
fmt
RanPix Jun 24, 2026
1d8cf49
Merge branch 'master' into esefeks
RanPix Jul 4, 2026
d2da43a
dont process sounds that are too far
RanPix Jul 14, 2026
92fcc3f
Merge branch 'master' into esefeks
RanPix Jul 14, 2026
f0781a4
allow loading audio files explicitly in sound asset files
RanPix Jul 15, 2026
943129d
tiny cleanup
RanPix Jul 15, 2026
22329c6
fmt
RanPix Jul 15, 2026
5a12f1b
add to reset
RanPix Jul 15, 2026
5f209d7
remove fixme
RanPix Jul 15, 2026
0de8631
Merge remote-tracking branch 'origin/master' into esefeks
RanPix Jul 15, 2026
8de387a
rename sounds in settings to audio
RanPix Jul 15, 2026
99ab682
refactor
RanPix Jul 15, 2026
0f6fc28
fmt
RanPix Jul 15, 2026
5c64c2d
remove cast
RanPix Jul 15, 2026
368835a
Merge branch 'smallAudioZigRefactor' into esefeks
RanPix Jul 15, 2026
adf1f8b
oops
RanPix Jul 15, 2026
e108ca9
Merge branch 'smallAudioZigRefactor' into esefeks
RanPix Jul 15, 2026
c902189
Merge remote-tracking branch 'origin/master' into smallAudioZigRefactor
RanPix Jul 19, 2026
79dee6c
review changes
RanPix Jul 19, 2026
e97295b
rename enum
RanPix Jul 19, 2026
ee0e6db
Merge remote-tracking branch 'origin/master' into esefeks
RanPix Jul 19, 2026
20d8d60
Merge branch 'smallAudioZigRefactor' into esefeks
RanPix Jul 19, 2026
241bf5b
add enum value
RanPix Jul 19, 2026
99b9fa0
Merge commit 'b9d3733366d6878b734b4732bcb0c58a70b86b98' into smallAud…
RanPix Jul 19, 2026
72087ec
Merge branch 'smallAudioZigRefactor' into esefeks
RanPix Jul 19, 2026
d5714c0
test and move to enum
RanPix Jul 19, 2026
d6d4fbd
error fixes or whatever
RanPix Jul 19, 2026
5c8d86f
remove no error check
RanPix Jul 19, 2026
092ceb7
now its good
RanPix Jul 19, 2026
7247c70
fixessss
RanPix Jul 19, 2026
09083b0
Merge branch 'smallAudioZigRefactor' into esefeks
RanPix Jul 19, 2026
f6dfb8e
fix sounds
RanPix Jul 19, 2026
35a4ed4
Merge remote-tracking branch 'origin/master' into esefeks
RanPix Jul 19, 2026
7516242
Merge remote-tracking branch 'origin/master' into esefeks
RanPix Jul 25, 2026
f5e5c18
lock the thread on adding
RanPix Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/cubyz/sounds/audio/block_break.ogg

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound effects should be in https://github.com/PixelGuys/Cubyz-Assets/ together with the music

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PixelGuys/Cubyz-Assets#39
i have a pr for it im just waiting for it to get added before i can add that functionality

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's waiting for artistic review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok i think i implemented it
its giving a error but i beleive its because the assets folder is not in yet

Binary file not shown.
Binary file added assets/cubyz/sounds/audio/block_hit.ogg
Binary file not shown.
Binary file added assets/cubyz/sounds/audio/jump.ogg
Binary file not shown.
4 changes: 4 additions & 0 deletions assets/cubyz/sounds/block_break.zig.zon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.{
.audio = "cubyz:block_break",
.volume = 1.25,
}
4 changes: 4 additions & 0 deletions assets/cubyz/sounds/block_hit.zig.zon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.{
.audio = "cubyz:block_hit",
.volume = 0.25,
}
4 changes: 4 additions & 0 deletions assets/cubyz/sounds/jump.zig.zon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.{
.audio = "cubyz:jump",
.volume = 0.35,
}
49 changes: 47 additions & 2 deletions src/assets.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const migrations = @import("migrations.zig");
const blueprint = @import("blueprint.zig");
const Blueprint = blueprint.Blueprint;
const particles = @import("particles.zig");
const audio = @import("audio.zig");
const ZonElement = main.ZonElement;
const biomes = main.server.terrain.biomes;
const sbb = main.server.terrain.sbb;
Expand Down Expand Up @@ -42,6 +43,8 @@ pub const Assets = struct {
worldPresets: ZonHashMap,
entityModelDescriptions: ZonHashMap,
entityModelMigrations: ZonHashMap,
audioDatas: main.List([]const u8),
Comment thread
IntegratedQuantum marked this conversation as resolved.
sounds: ZonHashMap,

fn init() Assets {
return .{
Expand All @@ -65,6 +68,8 @@ pub const Assets = struct {
.worldPresets = .{},
.entityModelDescriptions = .{},
.entityModelMigrations = .{},
.audioDatas = .empty,
.sounds = .{},
};
}
fn deinit(self: *Assets, allocator: NeverFailingAllocator) void {
Expand All @@ -88,6 +93,8 @@ pub const Assets = struct {
self.worldPresets.deinit(allocator.allocator);
self.entityModelDescriptions.deinit(allocator.allocator);
self.entityModelMigrations.deinit(allocator.allocator);
self.audioDatas.deinit(allocator);
self.sounds.deinit(allocator.allocator);
}
fn clone(self: Assets, allocator: NeverFailingAllocator) Assets {
return .{
Expand All @@ -111,6 +118,8 @@ pub const Assets = struct {
.worldPresets = .{}, // Not accessible inside the world
.entityModelDescriptions = self.entityModelDescriptions.clone(allocator.allocator) catch unreachable,
.entityModelMigrations = self.entityModelMigrations.clone(allocator.allocator) catch unreachable,
.audioDatas = self.audioDatas.clone(allocator),
.sounds = self.sounds.clone(allocator.allocator) catch unreachable,
};
}
fn read(self: *Assets, allocator: NeverFailingAllocator, assetDir: main.files.Dir, assetPath: []const u8) void {
Expand All @@ -133,12 +142,14 @@ pub const Assets = struct {
addon.readAllZon(allocator, "particles", true, &self.particles, null);
addon.readAllZon(allocator, "world_presets", true, &self.worldPresets, null);
addon.readAllZon(allocator, "entity_models", true, &self.entityModelDescriptions, &self.entityModelMigrations);
addon.readAllAudio(allocator, "sounds/audio", ".ogg", &self.audioDatas);
addon.readAllZon(allocator, "sounds", true, &self.sounds, null);
}
}
fn log(self: *Assets, typ: enum { common, world }) void {
std.log.info(
"Finished {s} assets reading with {} blocks, {} items, {} procedural items, {} biomes, {} cave layers, {} structure tables, {} recipes, {} structure building blocks, {} blueprints, {} particles, {} world presets, block models {}, and {} block model ZONs",
.{@tagName(typ), self.blocks.count(), self.items.count(), self.proceduralItems.count(), self.biomes.count(), self.caveLayers.count(), self.structureTables.count(), self.recipes.count(), self.structureBuildingBlocks.count(), self.blueprints.count(), self.particles.count(), self.worldPresets.count(), self.blockModels.count(), self.blockModelsZon.count()},
"Finished {s} assets reading with {} blocks, {} items, {} procedural items, {} biomes, {} cave layers, {} structure tables, {} recipes, {} structure building blocks, {} blueprints, {} particles, {} world presets, block models {}, {} block model ZONs, {} audio datas, and {} sounds",
.{@tagName(typ), self.blocks.count(), self.items.count(), self.proceduralItems.count(), self.biomes.count(), self.caveLayers.count(), self.structureTables.count(), self.recipes.count(), self.structureBuildingBlocks.count(), self.blueprints.count(), self.particles.count(), self.worldPresets.count(), self.blockModels.count(), self.blockModelsZon.count(), self.audioDatas.items.len, self.sounds.count()},
);
}

Expand Down Expand Up @@ -337,6 +348,30 @@ pub const Assets = struct {
output.put(allocator.allocator, id, string) catch unreachable;
}
}

pub fn readAllAudio(addon: Addon, allocator: NeverFailingAllocator, subPath: []const u8, fileEnding: []const u8, output: *main.List([]const u8)) void {
var assetsDirectory = addon.dir.openIterableDir(subPath) catch |err| {
if (err != error.FileNotFound) {
std.log.err("Could not open addon directory {s}: {s}", .{subPath, @errorName(err)});
}
return;
};
defer assetsDirectory.close();
var walker = assetsDirectory.walk(main.stackAllocator);
defer walker.deinit();

while (walker.next(main.io) catch |err| blk: {
std.log.err("Got error while iterating addon directory {s}: {s}", .{subPath, @errorName(err)});
break :blk null;
}) |entry| {
if (entry.kind != .file) continue;
if (!std.ascii.endsWithIgnoreCase(entry.basename, fileEnding)) continue;

const id = createAssetStringID(allocator, addon.name, "audio", entry.path) catch continue;

output.append(allocator, id);
}
}
};
};

Expand Down Expand Up @@ -709,6 +744,15 @@ pub fn loadWorldAssets(assetFolder: []const u8, blockPalette: *Palette, itemPale
particles.ParticleManager.register(assetFolder, entry.key_ptr.*, entry.value_ptr.*);
}

for (worldAssets.audioDatas.items) |entry| {
audio.registerAudioData(assetFolder, entry);
}

iterator = worldAssets.sounds.iterator();
while (iterator.next()) |entry| {
audio.registerSound(assetFolder, entry.key_ptr.*, entry.value_ptr.*);
}

// Biomes:
var nextBiomeNumericId: u32 = 0;
for (biomePalette.palette.items) |id| {
Expand Down Expand Up @@ -791,6 +835,7 @@ pub fn unloadAssets() void { // MARK: unloadAssets()
main.server.terrain.structures.reset();
main.models.reset();
main.particles.ParticleManager.reset();
main.audio.reset();
main.rotation.reset();
main.Tag.resetTags();
main.entityModel.reset();
Expand Down
164 changes: 164 additions & 0 deletions src/audio.zig
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const std = @import("std");

const main = @import("main");
const vec = main.vec;
const Vec3f = vec.Vec3f;
const utils = main.utils;
const ZonElement = @import("zon.zig").ZonElement;

const c = @import("c");

Expand Down Expand Up @@ -241,6 +244,23 @@ pub fn deinit() void {
preferredMusic.len = 0;
main.globalAllocator.free(activeMusicId);
activeMusicId.len = 0;

audioIdMap.deinit(main.globalAllocator.allocator);
audios.deinit(main.globalAllocator);
soundDataIdMap.deinit(main.globalAllocator.allocator);
soundDatas.deinit(main.globalAllocator);
activeSounds.deinit(main.globalAllocator);
}

pub fn reset() void {
audioIdMap.clearRetainingCapacity();
for (audios.items) |s| {
s.deinit();
}
audios.clearRetainingCapacity();
activeSounds.clearRetainingCapacity();
soundDataIdMap.clearRetainingCapacity();
soundDatas.clearRetainingCapacity();
}

const currentMusic = struct {
Expand Down Expand Up @@ -286,6 +306,79 @@ pub fn setMusic(music: []const u8) void {
preferredMusic = main.globalAllocator.dupe(u8, music);
}

const SoundData = struct { // MARK: Sounds
audioIndex: u32,
volume: f32 = 1,
};

const PlayingSound = struct {
pos: Vec3f = @splat(0),
audioIndex: u32,
bufPos: u32 = 0,

volume: f32 = 1,
maxDistance: f32 = 10,
isSpatial: bool = false,
};

var audioIdMap: std.StringHashMapUnmanaged(u32) = .{};
var audios: main.List(*AudioData) = .empty;
var soundDataIdMap: std.StringHashMapUnmanaged(u32) = .{};
var soundDatas: main.List(SoundData) = .empty;
var activeSounds: main.List(PlayingSound) = .empty;

pub fn getActiveSoundCount() u32 {
return @intCast(activeSounds.items.len);
}

pub fn registerAudioData(_: []const u8, id: []const u8) void {
const audio = AudioData.init(id, "sounds/audio");
audioIdMap.put(main.globalAllocator.allocator, id, @intCast(audios.items.len)) catch unreachable;
audios.append(main.globalAllocator, audio);
std.log.debug("Registered sound audio: {s}", .{id});
}

pub fn registerSound(assetsFolder: []const u8, id: []const u8, zon: ZonElement) void {
const audioId = zon.get([]const u8, "audio") orelse {
std.log.err("Sound Data audio was not specified: {s} ({s})", .{id, assetsFolder});
return;
};

soundDataIdMap.put(main.globalAllocator.allocator, id, @intCast(soundDatas.items.len)) catch unreachable;
soundDatas.append(main.globalAllocator, SoundData{
.audioIndex = audioIdMap.get(audioId) orelse {
std.log.err("Sound Data audio could not be found: {s} ({s}) audio ID: {s}", .{id, assetsFolder, audioId});
return;
},
.volume = zon.get(f32, "volume") orelse 1.0,
});

std.log.debug("Registered sound data: {s}", .{id});
}

fn addSound(id: []const u8, pos: Vec3f, maxDistance: f32, isSpatial: bool) void {
Comment thread
IntegratedQuantum marked this conversation as resolved.
mutex.lock();
defer mutex.unlock();

const idx = soundDataIdMap.get(id) orelse return;
const soundData = soundDatas.items[idx];
activeSounds.append(main.globalAllocator, PlayingSound{
Comment thread
IntegratedQuantum marked this conversation as resolved.
.audioIndex = soundData.audioIndex,
.volume = soundData.volume,
.pos = pos,
.isSpatial = isSpatial,
.maxDistance = maxDistance,
});
}

pub fn playSound(id: []const u8) void {
addSound(id, @splat(0), 0, false);
}

pub fn playSpatialSound(id: []const u8, pos: Vec3f, maxDistance: f32) void {
addSound(id, pos, maxDistance, true);
}

fn mixMusic(buffer: []f32) void {
mutex.lock();
defer mutex.unlock();
Expand Down Expand Up @@ -333,6 +426,76 @@ fn mixMusic(buffer: []f32) void {
}
}

fn mixSound(buffer: []f32) void {
mutex.lock();
defer mutex.unlock();

if (activeSounds.items.len == 0) return;

const playerPos: Vec3f = @floatCast(main.game.Player.getPosBlocking());
const playerForward = main.game.camera.direction;
const playerRight = vec.normalize(vec.cross(playerForward, Vec3f{0, 0, 1}));

var i: u32 = 0;
var soundCount = activeSounds.items.len;
main: while (i < soundCount) {
var sound = activeSounds.items[i];
const audioData = audios.items[sound.audioIndex];
const soundBuffer = audioData.data;

const notMonoInt: u32 = @intFromBool(audioData.channelType != .mono);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we forbid stereo tracks entirely? Ideally enforced through the conversion script in Cubyz-Assets.

@RanPix RanPix Jul 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ikabod-kee do we want stereo sounds? (not the directional spatial stuff in the game but the audio itself being stereo), having no stereo sounds would make things simplier

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setro tracks are just interpereted as mono with the first channel only being read

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't just blindly reinterpret stuff, log an error instead and discard the track.
It's potentially losing data and wasting performance, so I think we shouldn't allow it in the first place.
It's easy to reformat tracks as mono with any audio editor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok throws a error and returns a null now

const bufferStep: u32 = 1 + notMonoInt;

var leftVol: f32 = 1;
var rightVol: f32 = 1;

if (sound.isSpatial) {
const toSound = sound.pos - playerPos;
const distance: f32 = vec.length(toSound);

if (distance > sound.maxDistance) {
sound.bufPos += @intCast(if (audioData.channelType == .mono) @divFloor(buffer.len, 2) else buffer.len);
if (sound.bufPos >= soundBuffer.len) {
soundCount -= 1;
activeSounds.items[i] = activeSounds.items[soundCount];
Comment thread
IntegratedQuantum marked this conversation as resolved.
continue :main;
}
}

const pan: f32 = vec.dot(toSound/@as(Vec3f, @splat(distance)), playerRight);

const angle = (pan + 1)*0.25*std.math.pi;
leftVol = @cos(angle);
rightVol = @sin(angle);

var volume: f32 = 1 - distance/sound.maxDistance;

volume = if (volume < 0) 0 else volume;
leftVol *= volume;
rightVol *= volume;
}

var j: usize = 0;
while (j < buffer.len) : (j += 2) {
const amplitude: f32 = main.settings.soundVolume*sound.volume;

buffer[j] += soundBuffer[sound.bufPos]*amplitude*leftVol;
buffer[j + 1] += soundBuffer[sound.bufPos + notMonoInt]*amplitude*rightVol;
sound.bufPos += bufferStep;

if (sound.bufPos >= soundBuffer.len) {
soundCount -= 1;
activeSounds.items[i] = activeSounds.items[soundCount];
continue :main;
}
}
activeSounds.items[i] = sound;
i += 1;
}

activeSounds.items.len = soundCount;
}

fn miniaudioCallback(
maDevice: ?*anyopaque,
output: ?*anyopaque,
Expand All @@ -345,4 +508,5 @@ fn miniaudioCallback(
const buffer = @as([*]f32, @ptrCast(@alignCast(output)))[0..valuesPerBuffer];
@memset(buffer, 0);
mixMusic(buffer);
mixSound(buffer);
}
9 changes: 9 additions & 0 deletions src/game.zig
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,8 @@ pub fn getBlockWithSide(comptime side: main.sync.Side, x: i32, y: i32, z: i32) ?
}
}

var timeing: f64 = 0;

pub fn update(deltaTime: f64) void { // MARK: update()
if (world.?.shouldRestart.load(.acquire)) {
restart();
Expand Down Expand Up @@ -606,6 +608,12 @@ pub fn update(deltaTime: f64) void { // MARK: update()
const right = Vec3d{-horizontalForward[1], horizontalForward[0], 0};
var movementDir: Vec3d = .{0, 0, 0};

timeing += deltaTime;
if (timeing > 0.5) {
main.audio.playSpatialSound("cubyz:block_break", Vec3f{0, 0, 0}, 20);
timeing = 0;
}

if (main.Window.grabbed) {
const walkingSpeed: f64 = if (Player.crouching) 2.5 else 4.5;
var movementSpeed: f64 = walkingSpeed*@min(1, vec.length(Vec2f{
Expand Down Expand Up @@ -652,6 +660,7 @@ pub fn update(deltaTime: f64) void { // MARK: update()
movementDir[2] += 5.5;
}
} else if ((Player.onGround or Player.jumpCoyote > 0.0) and Player.jumpCooldown <= 0) {
main.audio.playSound("cubyz:jump");
jumping = true;
Player.jumpCooldown = Player.jumpCooldownConstant;
if (!Player.onGround) {
Expand Down
12 changes: 12 additions & 0 deletions src/gui/windows/audio.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ fn musicCallback(newValue: f32) void {
settings.save();
}

fn soundCallback(newValue: f32) void {
settings.soundVolume = deziBelToLinear(newValue);
settings.save();
}

fn deziBelToLinear(x: f32) f32 {
if (x < -59.95) return 0;
return std.math.pow(f32, 10, x/20);
Expand All @@ -39,11 +44,18 @@ fn musicFormatter(allocator: NeverFailingAllocator, value: f32) []const u8 {
return allocator.print("Music volume:", .{});
}

fn soundFormatter(allocator: NeverFailingAllocator, value: f32) []const u8 {
const percentage = 100*deziBelToLinear(value);
Comment thread
IntegratedQuantum marked this conversation as resolved.
if (percentage == 0) return allocator.dupe(u8, "Sound volume: Off");
return std.fmt.allocPrint(allocator.allocator, "Sound volume:", .{}) catch unreachable;
}

const padding: f32 = 8;

pub fn onOpen() void {
const list = VerticalList.init(.{padding, 16 + padding}, 300, 16);
list.add(ContinuousSlider.init(.{0, 0}, 128, -60, 0, linearToDezibel(settings.musicVolume), &musicCallback, &musicFormatter));
list.add(ContinuousSlider.init(.{0, 0}, 128, -60, 0, linearToDezibel(settings.soundVolume), &soundCallback, &soundFormatter));
list.finish(.center);
window.rootComponent = list.toComponent();
window.contentSize = window.rootComponent.?.pos() + window.rootComponent.?.size() + @as(Vec2f, @splat(padding));
Expand Down
2 changes: 2 additions & 0 deletions src/gui/windows/debug.zig
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,7 @@ pub fn render() void {
y += 8;
draw.print("items: {} entities: {}", .{main.game.world.?.itemDrops.super.size, main.client.entity_manager.entities.len}, 0, y, 8);
y += 8;
draw.print("Active sound count: {}", .{main.audio.getActiveSoundCount()}, 0, y, 8);
y += 8;
}
}
Loading
Loading