Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions src/engine/core/time.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
const std = @import("std");
const c = @import("../../c.zig").c;

pub fn timestampMs() i64 {
const inst = std.time.Instant.now() catch return 0;
const sec: i64 = inst.timestamp.sec;
const nsec: i64 = inst.timestamp.nsec;
const ms_from_sec = std.math.mul(i64, sec, std.time.ms_per_s) catch return std.math.maxInt(i64);
const ms_from_nsec = @divTrunc(nsec, std.time.ns_per_ms);
return std.math.add(i64, ms_from_sec, ms_from_nsec) catch return std.math.maxInt(i64);
}

pub const Time = struct {
/// Time since last frame in seconds
delta_time: f32 = 0,
Expand Down
10 changes: 10 additions & 0 deletions src/game/session.zig
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ pub const GameSession = struct {
.creative_mode = true,
};

const save_env = std.posix.getenv("ZIGCRAFT_SAVE_DIR");
if (save_env) |save_path| {
world.enableSaveManager(save_path, "world") catch |err| {
log.log.warn("Failed to initialize save manager: {}", .{err});
};
if (world.save_manager) |sm| {
world.streamer.setSaveManager(sm);
}
}

// Force map update initially
session.map_controller.map_needs_update = true;

Expand Down
2 changes: 2 additions & 0 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ test {
_ = @import("game/session_tests.zig");
_ = @import("world/persistence/region_file.zig");
_ = @import("world/persistence/chunk_serializer.zig");
_ = @import("world/persistence/level_data.zig");
_ = @import("world/persistence/save_manager.zig");
_ = @import("world/meshing/quadric_simplifier.zig");
}

Expand Down
4 changes: 4 additions & 0 deletions src/world/chunk.zig
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ pub const Chunk = struct {
/// Has this chunk been generated?
generated: bool = false,

/// Has this chunk been modified since last save?
modified: bool = false,

/// Number of active jobs referencing this chunk (prevents unloading)
pin_count: std.atomic.Value(u32),

Expand Down Expand Up @@ -169,6 +172,7 @@ pub const Chunk = struct {
pub fn setBlock(self: *Chunk, x: u32, y: u32, z: u32, block: BlockType) void {
self.blocks[getIndex(x, y, z)] = block;
self.dirty = true;
self.modified = true;
}

/// Get block with bounds checking (returns air if out of bounds)
Expand Down
133 changes: 133 additions & 0 deletions src/world/persistence/level_data.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Level metadata for world saves.
//!
//! Manages the `level.dat` JSON file that stores world metadata such as
//! seed, generator type, timestamps, and spawn position.

const std = @import("std");
const Allocator = std.mem.Allocator;
const log = @import("../../engine/core/log.zig");
const timestampMs = @import("../../engine/core/time.zig").timestampMs;

pub const LevelData = struct {
seed: u64,
generator_name: []const u8,
created_timestamp: i64,
last_played_timestamp: i64,
spawn_x: i32,
spawn_z: i32,

pub fn init(seed: u64, generator_name: []const u8) LevelData {
const now = timestampMs();
return .{
.seed = seed,
.generator_name = generator_name,
.created_timestamp = now,
.last_played_timestamp = now,
.spawn_x = 8,
.spawn_z = 8,
};
}

pub fn deinit(self: *LevelData, allocator: Allocator) void {
if (self.generator_name.len > 0) {
allocator.free(self.generator_name);
}
}

pub fn saveToFile(self: *const LevelData, allocator: Allocator, dir: std.fs.Dir) !void {
var aw: std.Io.Writer.Allocating = try .initCapacity(allocator, 256);
defer aw.deinit();

const writer = &aw.writer;
try writer.writeAll("{\n");
try writer.print(" \"seed\": {},\n", .{self.seed});
try writer.print(" \"generator_name\": \"{s}\",\n", .{self.generator_name});
try writer.print(" \"created_timestamp\": {},\n", .{self.created_timestamp});
try writer.print(" \"last_played_timestamp\": {},\n", .{self.last_played_timestamp});
try writer.print(" \"spawn_x\": {},\n", .{self.spawn_x});
try writer.print(" \"spawn_z\": {}\n", .{self.spawn_z});
try writer.writeAll("}");

const file = try dir.createFile("level.dat", .{ .truncate = true });
defer file.close();
try file.writeAll(aw.written());
}

pub fn loadFromFile(allocator: Allocator, dir: std.fs.Dir) !LevelData {
const file = try dir.openFile("level.dat", .{});
defer file.close();

const stat = try file.stat();
if (stat.size > 4096) return error.LevelDataTooLarge;

const contents = try allocator.alloc(u8, @intCast(stat.size));
defer allocator.free(contents);
_ = try file.preadAll(contents, 0);

var result = LevelData{
.seed = 0,
.generator_name = "",
.created_timestamp = 0,
.last_played_timestamp = 0,
.spawn_x = 8,
.spawn_z = 8,
};

var lines = std.mem.splitSequence(u8, contents, "\n");
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r,");
if (trimmed.len == 0 or trimmed[0] == '{' or trimmed[0] == '}') continue;

if (std.mem.indexOf(u8, trimmed, ":")) |colon_idx| {
const key = std.mem.trim(u8, trimmed[0..colon_idx], " \"");
const val = std.mem.trim(u8, trimmed[colon_idx + 1 ..], " \"");

if (std.mem.eql(u8, key, "seed")) {
result.seed = std.fmt.parseInt(u64, val, 10) catch 0;
} else if (std.mem.eql(u8, key, "generator_name")) {
result.generator_name = try allocator.dupe(u8, val);
} else if (std.mem.eql(u8, key, "created_timestamp")) {
result.created_timestamp = std.fmt.parseInt(i64, val, 10) catch 0;
} else if (std.mem.eql(u8, key, "last_played_timestamp")) {
result.last_played_timestamp = std.fmt.parseInt(i64, val, 10) catch 0;
} else if (std.mem.eql(u8, key, "spawn_x")) {
result.spawn_x = std.fmt.parseInt(i32, val, 10) catch 8;
} else if (std.mem.eql(u8, key, "spawn_z")) {
result.spawn_z = std.fmt.parseInt(i32, val, 10) catch 8;
}
}
}

return result;
}

pub fn touchLastPlayed(self: *LevelData) void {
self.last_played_timestamp = timestampMs();
}
};

const testing = std.testing;

test "LevelData save and load round-trip" {
var tmp_dir = testing.tmpDir(.{});
defer tmp_dir.cleanup();

const original = LevelData.init(12345, "overworld");
try original.saveToFile(testing.allocator, tmp_dir.dir);

var loaded = try LevelData.loadFromFile(testing.allocator, tmp_dir.dir);
defer loaded.deinit(testing.allocator);

try testing.expectEqual(@as(u64, 12345), loaded.seed);
try testing.expectEqualStrings("overworld", loaded.generator_name);
try testing.expectEqual(@as(i32, 8), loaded.spawn_x);
try testing.expectEqual(@as(i32, 8), loaded.spawn_z);
}

test "LevelData touchLastPlayed updates timestamp" {
var data = LevelData.init(99999, "flat");
const old_ts = data.last_played_timestamp;
std.posix.nanosleep(0, 1_000_000);
data.touchLastPlayed();
try testing.expect(data.last_played_timestamp >= old_ts);
}
Loading
Loading