From 7f4406145e39324527ed3b5d59b32616ebfed602 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 7 Jul 2026 03:56:33 +0100 Subject: [PATCH 1/3] fix(audit): resolve p2 world and rhi issues --- build.zig | 1 + modules/engine-graphics/src/render_system.zig | 14 +---- modules/engine-graphics/src/rhi_vulkan.zig | 4 +- .../src/vulkan/rhi_render_state.zig | 18 +++--- modules/engine-rhi/src/rhi.zig | 11 ++-- modules/engine-rhi/src/rhi_types.zig | 14 +++++ modules/engine-rhi/src/root.zig | 1 + .../engine-ui/src/chunk_inspector_overlay.zig | 19 +----- modules/game-core/src/player.zig | 9 ++- modules/game-ui/src/screens/world.zig | 14 ++++- modules/game-ui/src/screens/world_list.zig | 63 ++++++++++++------- modules/world-core/src/root.zig | 3 + modules/world-core/src/telemetry.zig | 17 +++++ .../world-persistence/src/save_manager.zig | 18 +++++- .../src/chunk_queue_coordinator.zig | 9 ++- modules/world-runtime/src/world.zig | 12 +++- modules/world-runtime/src/world_streamer.zig | 6 +- modules/worldgen-api/src/root.zig | 10 ++- modules/worldgen-flat/src/root.zig | 8 +-- modules/worldgen-overworld-v2/src/root.zig | 12 ++-- .../src/overworld_generator.zig | 7 ++- modules/worldgen-test/src/root.zig | 10 +-- src/game/app.zig | 14 ++++- src/worldgen_tests.zig | 44 ++++++------- 24 files changed, 217 insertions(+), 121 deletions(-) create mode 100644 modules/world-core/src/telemetry.zig diff --git a/build.zig b/build.zig index 19059dc5..4189c652 100644 --- a/build.zig +++ b/build.zig @@ -222,6 +222,7 @@ pub fn build(b: *std.Build) void { engine_ui.addImport("engine-math", engine_math); engine_ui.addImport("engine-core", engine_core); engine_ui.addImport("engine-rhi", engine_rhi); + engine_ui.addImport("world-core", world_core); engine_ui.addOptions("engine_ui_options", engine_ui_options); engine_ui.linkSystemLibrary("sdl3", .{}); engine_ui.linkSystemLibrary("vulkan", .{}); diff --git a/modules/engine-graphics/src/render_system.zig b/modules/engine-graphics/src/render_system.zig index a4abefbc..fef6d2b4 100644 --- a/modules/engine-graphics/src/render_system.zig +++ b/modules/engine-graphics/src/render_system.zig @@ -320,20 +320,10 @@ pub const RenderSystem = struct { pub fn updateGlobalUniforms( self: *RenderSystem, - view_proj: Mat4, - cam_pos: Vec3, - sun_dir: Vec3, - sun_color: Vec3, - time: f32, - fog_color: Vec3, - fog_density: f32, - fog_enabled: bool, - sun_intensity: f32, - ambient: f32, - use_texture: bool, + uniforms: rhi_pkg.GlobalUniforms, frame_params: rhi_pkg.FrameRenderParams, ) !void { - try self.rhi.renderContext().updateGlobalUniforms(view_proj, cam_pos, sun_dir, sun_color, time, fog_color, fog_density, fog_enabled, sun_intensity, ambient, use_texture, frame_params); + try self.rhi.renderContext().updateGlobalUniforms(uniforms, frame_params); } pub fn applyConfig(self: *RenderSystem, config: Config) void { diff --git a/modules/engine-graphics/src/rhi_vulkan.zig b/modules/engine-graphics/src/rhi_vulkan.zig index 76fc4644..2040cafe 100644 --- a/modules/engine-graphics/src/rhi_vulkan.zig +++ b/modules/engine-graphics/src/rhi_vulkan.zig @@ -455,9 +455,9 @@ fn waitIdle(ctx_ptr: *anyopaque) void { state_control.waitIdle(ctx); } -fn updateGlobalUniforms(ctx_ptr: *anyopaque, view_proj: Mat4, cam_pos: Vec3, sun_dir: Vec3, sun_color: Vec3, time_val: f32, fog_color: Vec3, fog_density: f32, fog_enabled: bool, sun_intensity: f32, ambient: f32, use_texture: bool, frame_params: rhi.FrameRenderParams) anyerror!void { +fn updateGlobalUniforms(ctx_ptr: *anyopaque, uniforms: rhi.GlobalUniforms, frame_params: rhi.FrameRenderParams) anyerror!void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); - try render_state.updateGlobalUniforms(ctx, view_proj, cam_pos, sun_dir, sun_color, time_val, fog_color, fog_density, fog_enabled, sun_intensity, ambient, use_texture, frame_params); + try render_state.updateGlobalUniforms(ctx, uniforms, frame_params); } fn setModelMatrix(ctx_ptr: *anyopaque, model: Mat4, color: Vec3, mask_radius: f32) void { diff --git a/modules/engine-graphics/src/vulkan/rhi_render_state.zig b/modules/engine-graphics/src/vulkan/rhi_render_state.zig index 87e64f0a..938332c7 100644 --- a/modules/engine-graphics/src/vulkan/rhi_render_state.zig +++ b/modules/engine-graphics/src/vulkan/rhi_render_state.zig @@ -29,17 +29,17 @@ const GlobalUniforms = extern struct { lpv_origin: [4]f32, }; -pub fn updateGlobalUniforms(ctx: anytype, view_proj: Mat4, cam_pos: Vec3, sun_dir: Vec3, sun_color: Vec3, time_val: f32, fog_color: Vec3, fog_density: f32, fog_enabled: bool, sun_intensity: f32, ambient: f32, use_texture: bool, frame_params: rhi.FrameRenderParams) !void { +pub fn updateGlobalUniforms(ctx: anytype, uniforms: rhi.GlobalUniforms, frame_params: rhi.FrameRenderParams) !void { const global_uniforms = GlobalUniforms{ - .view_proj = view_proj, + .view_proj = uniforms.view_proj, .view_proj_prev = ctx.velocity.view_proj_prev, - .cam_pos = .{ cam_pos.x, cam_pos.y, cam_pos.z, 1.0 }, - .sun_dir = .{ sun_dir.x, sun_dir.y, sun_dir.z, 0.0 }, - .sun_color = .{ sun_color.x, sun_color.y, sun_color.z, 1.0 }, - .fog_color = .{ fog_color.x, fog_color.y, fog_color.z, 1.0 }, + .cam_pos = .{ uniforms.cam_pos.x, uniforms.cam_pos.y, uniforms.cam_pos.z, 1.0 }, + .sun_dir = .{ uniforms.sun_dir.x, uniforms.sun_dir.y, uniforms.sun_dir.z, 0.0 }, + .sun_color = .{ uniforms.sun_color.x, uniforms.sun_color.y, uniforms.sun_color.z, 1.0 }, + .fog_color = .{ uniforms.fog_color.x, uniforms.fog_color.y, uniforms.fog_color.z, 1.0 }, .reserved0 = .{ 0.0, 0.0, 0.0, 0.0 }, - .params = .{ time_val, fog_density, if (fog_enabled) 1.0 else 0.0, sun_intensity }, - .lighting = .{ ambient, if (use_texture) 1.0 else 0.0, if (frame_params.pbr_enabled) 1.0 else 0.0, 0.0 }, + .params = .{ uniforms.time, uniforms.fog_density, if (uniforms.fog_enabled) 1.0 else 0.0, uniforms.sun_intensity }, + .lighting = .{ uniforms.ambient, if (uniforms.use_texture) 1.0 else 0.0, if (frame_params.pbr_enabled) 1.0 else 0.0, 0.0 }, .render_flags = .{ 0.0, 0.0, if (frame_params.pbr_enabled) 1.0 else 0.0, if (frame_params.simple_lighting_enabled) 1.0 else 0.0 }, .shadow_params = .{ @floatFromInt(frame_params.shadow.pcf_samples), if (frame_params.shadow.cascade_blend) 1.0 else 0.0, frame_params.shadow.strength, if (frame_params.shadow_apply_to_beauty) 1.0 else 0.0 }, .pbr_params = .{ @floatFromInt(frame_params.pbr_quality), frame_params.exposure, frame_params.saturation, if (frame_params.ssao_enabled) 1.0 else 0.0 }, @@ -62,7 +62,7 @@ pub fn updateGlobalUniforms(ctx: anytype, view_proj: Mat4, cam_pos: Vec3, sun_di } try ctx.descriptors.updateGlobalUniforms(ctx.frames.current_frame, &global_uniforms); - ctx.velocity.view_proj_prev = view_proj; + ctx.velocity.view_proj_prev = uniforms.view_proj; } pub fn setModelMatrix(ctx: anytype, model: Mat4, color: Vec3, mask_radius: f32) void { diff --git a/modules/engine-rhi/src/rhi.zig b/modules/engine-rhi/src/rhi.zig index ef5eb2d5..47031f38 100644 --- a/modules/engine-rhi/src/rhi.zig +++ b/modules/engine-rhi/src/rhi.zig @@ -89,6 +89,7 @@ pub const InstanceData = rhi_types.InstanceData; pub const SkyParams = rhi_types.SkyParams; pub const SkyPushConstants = rhi_types.SkyPushConstants; pub const FrameRenderParams = rhi_types.FrameRenderParams; +pub const GlobalUniforms = rhi_types.GlobalUniforms; pub const ShadowConfig = rhi_types.ShadowConfig; pub const ShadowParams = rhi_types.ShadowParams; pub const Color = rhi_types.Color; @@ -351,8 +352,8 @@ pub const RenderContext = struct { pub fn setSelectionMode(self: RenderContext, enabled: bool) void { self.state.setSelectionMode(enabled); } - pub fn updateGlobalUniforms(self: RenderContext, view_proj: Mat4, cam_pos: Vec3, sun_dir: Vec3, sun_color: Vec3, time: f32, fog_color: Vec3, fog_density: f32, fog_enabled: bool, sun_intensity: f32, ambient: f32, use_texture: bool, frame_params: FrameRenderParams) !void { - try self.state.updateGlobalUniforms(view_proj, cam_pos, sun_dir, sun_color, time, fog_color, fog_density, fog_enabled, sun_intensity, ambient, use_texture, frame_params); + pub fn updateGlobalUniforms(self: RenderContext, uniforms: GlobalUniforms, frame_params: FrameRenderParams) !void { + try self.state.updateGlobalUniforms(uniforms, frame_params); } pub fn setTextureUniforms(self: RenderContext, texture_enabled: bool, shadow_map_handles: [SHADOW_CASCADE_COUNT]TextureHandle) void { self.state.setTextureUniforms(texture_enabled, shadow_map_handles); @@ -618,7 +619,7 @@ pub const IRenderStateContext = struct { setLODInstanceBuffer: *const fn (ptr: *anyopaque, handle: BufferHandle) void, setTerrainPipelineBound: *const fn (ptr: *anyopaque, bound: bool) void, setSelectionMode: *const fn (ptr: *anyopaque, enabled: bool) void, - updateGlobalUniforms: *const fn (ptr: *anyopaque, view_proj: Mat4, cam_pos: Vec3, sun_dir: Vec3, sun_color: Vec3, time: f32, fog_color: Vec3, fog_density: f32, fog_enabled: bool, sun_intensity: f32, ambient: f32, use_texture: bool, frame_params: FrameRenderParams) anyerror!void, + updateGlobalUniforms: *const fn (ptr: *anyopaque, uniforms: GlobalUniforms, frame_params: FrameRenderParams) anyerror!void, setTextureUniforms: *const fn (ptr: *anyopaque, texture_enabled: bool, shadow_map_handles: [SHADOW_CASCADE_COUNT]TextureHandle) void, }; @@ -637,8 +638,8 @@ pub const IRenderStateContext = struct { pub fn setSelectionMode(self: IRenderStateContext, enabled: bool) void { self.vtable.setSelectionMode(self.ptr, enabled); } - pub fn updateGlobalUniforms(self: IRenderStateContext, view_proj: Mat4, cam_pos: Vec3, sun_dir: Vec3, sun_color: Vec3, time: f32, fog_color: Vec3, fog_density: f32, fog_enabled: bool, sun_intensity: f32, ambient: f32, use_texture: bool, frame_params: FrameRenderParams) !void { - try self.vtable.updateGlobalUniforms(self.ptr, view_proj, cam_pos, sun_dir, sun_color, time, fog_color, fog_density, fog_enabled, sun_intensity, ambient, use_texture, frame_params); + pub fn updateGlobalUniforms(self: IRenderStateContext, uniforms: GlobalUniforms, frame_params: FrameRenderParams) !void { + try self.vtable.updateGlobalUniforms(self.ptr, uniforms, frame_params); } pub fn setTextureUniforms(self: IRenderStateContext, texture_enabled: bool, shadow_map_handles: [SHADOW_CASCADE_COUNT]TextureHandle) void { self.vtable.setTextureUniforms(self.ptr, texture_enabled, shadow_map_handles); diff --git a/modules/engine-rhi/src/rhi_types.zig b/modules/engine-rhi/src/rhi_types.zig index d7141559..810b75dc 100644 --- a/modules/engine-rhi/src/rhi_types.zig +++ b/modules/engine-rhi/src/rhi_types.zig @@ -323,6 +323,20 @@ pub const FrameRenderParams = struct { lpv_origin: Vec3 = Vec3.init(0.0, 0.0, 0.0), }; +pub const GlobalUniforms = struct { + view_proj: Mat4, + cam_pos: Vec3, + sun_dir: Vec3, + sun_color: Vec3, + time: f32, + fog_color: Vec3, + fog_density: f32, + fog_enabled: bool, + sun_intensity: f32, + ambient: f32, + use_texture: bool, +}; + pub const Color = struct { r: f32, g: f32, diff --git a/modules/engine-rhi/src/root.zig b/modules/engine-rhi/src/root.zig index 96bbf7d9..51039900 100644 --- a/modules/engine-rhi/src/root.zig +++ b/modules/engine-rhi/src/root.zig @@ -43,6 +43,7 @@ pub const InstanceData = rhi_types.InstanceData; pub const SkyParams = rhi_types.SkyParams; pub const SkyPushConstants = rhi_types.SkyPushConstants; pub const FrameRenderParams = rhi_types.FrameRenderParams; +pub const GlobalUniforms = rhi_types.GlobalUniforms; pub const ShadowConfig = rhi_types.ShadowConfig; pub const ShadowParams = rhi_types.ShadowParams; pub const Color = rhi_types.Color; diff --git a/modules/engine-ui/src/chunk_inspector_overlay.zig b/modules/engine-ui/src/chunk_inspector_overlay.zig index 0657f859..a9007a8f 100644 --- a/modules/engine-ui/src/chunk_inspector_overlay.zig +++ b/modules/engine-ui/src/chunk_inspector_overlay.zig @@ -10,23 +10,8 @@ pub const ChunkRenderStats = struct { vertices_rendered: u64 = 0, }; -pub const ChunkStateCounts = struct { - total: u32 = 0, - missing: u32 = 0, - generating: u32 = 0, - meshing: u32 = 0, - renderable: u32 = 0, - other_states: u32 = 0, - dirty: u32 = 0, -}; - -pub const WorldStateData = struct { - generator_name: []const u8, - seed: u64, - gen_queue: u32, - mesh_queue: u32, - upload_queue: u32, -}; +pub const ChunkStateCounts = @import("world-core").ChunkStateCounts; +pub const WorldStateData = @import("world-core").WorldStateData; pub const ChunkInspectorOverlay = struct { enabled: bool = false, diff --git a/modules/game-core/src/player.zig b/modules/game-core/src/player.zig index 017b72db..b9b857e9 100644 --- a/modules/game-core/src/player.zig +++ b/modules/game-core/src/player.zig @@ -12,6 +12,7 @@ const Camera = @import("engine-camera").Camera; const Input = @import("engine-input").Input; const IRawInputProvider = @import("engine-input").IRawInputProvider; const Key = @import("engine-core").interfaces.Key; +const log = @import("engine-core").log; const MouseButton = @import("engine-core").interfaces.MouseButton; const IWorldSimulation = @import("world-runtime").IWorldSimulation; const collision = @import("engine-physics").collision; @@ -383,7 +384,9 @@ pub const Player = struct { /// Break the currently targeted block (set to air) pub fn breakTargetBlock(self: *Player, world: IWorldSimulation) void { if (self.target_block) |target| { - world.setBlock(target.x, target.y, target.z, .air) catch {}; + world.setBlock(target.x, target.y, target.z, .air) catch |err| { + log.log.warn("Block break failed at ({}, {}, {}): {}", .{ target.x, target.y, target.z, err }); + }; } } @@ -402,7 +405,9 @@ pub const Player = struct { ); if (!self.getAABB().intersects(place_aabb)) { - world.setBlock(px, py, pz, block_type) catch {}; + world.setBlock(px, py, pz, block_type) catch |err| { + log.log.warn("Block place failed at ({}, {}, {}): {}", .{ px, py, pz, err }); + }; } } } diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index 2afcf504..c1b83ecc 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -394,7 +394,19 @@ pub const WorldScreen = struct { std.math.clamp(boosted_horizon.z, 0.0, 1.0), ); rhi.renderContext().setClearColor(clear_color); - try rhi.renderContext().updateGlobalUniforms(view_proj_render, camera.position, render_sun_dir, self.session.atmosphere.sun_color, self.session.atmosphere.time.time_of_day, self.session.atmosphere.fog_color, self.session.atmosphere.fog_density, self.session.atmosphere.fog_enabled and !safe_mode, self.session.atmosphere.sun_intensity, self.session.atmosphere.ambient_intensity, ctx.settings.textures_enabled, frame_params); + try rhi.renderContext().updateGlobalUniforms(.{ + .view_proj = view_proj_render, + .cam_pos = camera.position, + .sun_dir = render_sun_dir, + .sun_color = self.session.atmosphere.sun_color, + .time = self.session.atmosphere.time.time_of_day, + .fog_color = self.session.atmosphere.fog_color, + .fog_density = self.session.atmosphere.fog_density, + .fog_enabled = self.session.atmosphere.fog_enabled and !safe_mode, + .sun_intensity = self.session.atmosphere.sun_intensity, + .ambient = self.session.atmosphere.ambient_intensity, + .use_texture = ctx.settings.textures_enabled, + }, frame_params); const env_map_ptr = render_system.getEnvMapPtr(); const env_map_handle = if (env_map_ptr.*) |t| t.handle else 0; diff --git a/modules/game-ui/src/screens/world_list.zig b/modules/game-ui/src/screens/world_list.zig index 4280902f..17aeced1 100644 --- a/modules/game-ui/src/screens/world_list.zig +++ b/modules/game-ui/src/screens/world_list.zig @@ -159,20 +159,13 @@ fn compareWorldsByLastPlayed(_: void, a: WorldEntry, b: WorldEntry) bool { return a.last_played > b.last_played; } -/// Deletes a world directory and frees dir_path. -/// Logs errors but does not return them. Caller must not use dir_path after call. -pub fn deleteWorld(allocator: std.mem.Allocator, dir_path: []const u8) void { - const parent_path = fs.path.dirname(dir_path) orelse return; +/// Deletes a world directory. Caller owns and frees dir_path. +pub fn deleteWorld(dir_path: []const u8) !void { + const parent_path = fs.path.dirname(dir_path) orelse return error.InvalidSavePath; const base = fs.path.basename(dir_path); - var parent = fs.openDirAbsolute(parent_path, .{ .iterate = true }) catch |err| { - log.log.err("Failed to open parent dir for deletion: {}", .{err}); - return; - }; + var parent = try fs.openDirAbsolute(parent_path, .{ .iterate = true }); defer parent.close(); - parent.deleteTree(base) catch |err| { - log.log.warn("Failed to remove world directory: {}", .{err}); - }; - allocator.free(dir_path); + try parent.deleteTree(base); } pub const WorldListScreen = struct { @@ -185,6 +178,7 @@ pub const WorldListScreen = struct { confirm_rename: bool, rename_buffer: std.ArrayListUnmanaged(u8), rename_focused: bool, + error_message: ?[]const u8, pub const vtable = IScreen.VTable{ .deinit = deinit, @@ -207,6 +201,7 @@ pub const WorldListScreen = struct { .confirm_rename = false, .rename_buffer = std.ArrayListUnmanaged(u8).empty, .rename_focused = false, + .error_message = null, }; return self; } @@ -234,6 +229,7 @@ pub const WorldListScreen = struct { } else if (self.confirm_clear_all) { self.confirm_clear_all = false; } else { + self.error_message = null; self.context.screen_manager.popScreen(); } } @@ -266,6 +262,13 @@ pub const WorldListScreen = struct { const py: f32 = (screen_h - ph) * 0.5; const shell = Theme.drawShell(ui, .{ .x = px, .y = py, .width = pw, .height = ph }, ui_scale, "SAVES", "WORLDS", "Load, rename, or remove saved worlds."); + if (self.error_message) |message| { + const banner = Rect{ .x = shell.content.x + 12.0 * ui_scale, .y = shell.content.y + 8.0 * ui_scale, .width = shell.content.width - 24.0 * ui_scale, .height = 34.0 * ui_scale }; + ui.drawRect(banner, Theme.Color.rgba(0.18, 0.04, 0.05, 0.92)); + ui.drawRectOutline(banner, Theme.danger, 1.0 * ui_scale); + Font.drawText(ui, message, banner.x + 12.0 * ui_scale, banner.y + 9.0 * ui_scale, 0.72 * ui_scale, Theme.text); + } + var count_buf: [64]u8 = undefined; const count_text = std.fmt.bufPrint(&count_buf, "{} WORLDS", .{self.worlds.len}) catch "?"; const count_w = Font.measureTextWidth(count_text, 0.94 * ui_scale); @@ -361,7 +364,10 @@ pub const WorldListScreen = struct { self.confirm_delete = false; } if (Theme.drawButton(ui, .{ .x = cx + cbw + 20.0 * ui_scale, .y = cby, .width = cbw, .height = 40.0 * ui_scale }, "CONFIRM", btn_scale, mx, my, mc, .danger, ui_scale)) { - self.confirmDelete(idx) catch {}; + self.confirmDelete(idx) catch |err| { + log.log.err("Failed to delete world '{s}': {}", .{ self.worlds[idx].name, err }); + self.error_message = "Failed to delete world. Check logs."; + }; } } } @@ -381,7 +387,10 @@ pub const WorldListScreen = struct { self.confirm_clear_all = false; } if (Theme.drawButton(ui, .{ .x = cx + cbw + 20.0 * ui_scale, .y = cby, .width = cbw, .height = 40.0 * ui_scale }, "CONFIRM", btn_scale, mx, my, mc, .danger, ui_scale)) { - self.clearAllWorlds() catch {}; + self.clearAllWorlds() catch |err| { + log.log.err("Failed to clear all worlds: {}", .{err}); + self.error_message = "Failed to clear worlds. Check logs."; + }; } } @@ -410,7 +419,10 @@ pub const WorldListScreen = struct { self.rename_buffer.clearRetainingCapacity(); } if (Theme.drawButton(ui, .{ .x = cx + cbw + 20.0 * ui_scale, .y = cby, .width = cbw, .height = 38.0 * ui_scale }, "OK", btn_scale, mx, my, mc, .primary, ui_scale)) { - self.renameWorld(idx) catch {}; + self.renameWorld(idx) catch |err| { + log.log.err("Failed to rename world '{s}': {}", .{ self.worlds[idx].name, err }); + self.error_message = "Failed to rename world. Check logs."; + }; } } } @@ -435,13 +447,13 @@ pub const WorldListScreen = struct { fn confirmDelete(self: *@This(), idx: usize) !void { const allocator = self.context.allocator; const dir_path = self.worlds[idx].dir_path; - deleteWorld(allocator, dir_path); + try deleteWorld(dir_path); const old_worlds = self.worlds; const empty_worlds = try allocator.alloc(WorldEntry, 0); self.worlds = empty_worlds; - for (old_worlds, 0..) |e, i| { + for (old_worlds) |e| { allocator.free(e.name); - if (i != idx and e.dir_path.len > 0) allocator.free(e.dir_path); + if (e.dir_path.len > 0) allocator.free(e.dir_path); } allocator.free(old_worlds); if (scanWorlds(allocator)) |new_worlds| { @@ -451,6 +463,7 @@ pub const WorldListScreen = struct { self.selected = null; self.confirm_delete = false; self.scroll_offset = 0.0; + self.error_message = null; } fn renameWorld(self: *@This(), idx: usize) !void { @@ -460,16 +473,14 @@ pub const WorldListScreen = struct { const new_name = try allocator.dupe(u8, trimmed); errdefer allocator.free(new_name); const world = self.worlds[idx]; - var save_dir = fs.openDirAbsolute(world.dir_path, .{}) catch return; + var save_dir = try fs.openDirAbsolute(world.dir_path, .{}); defer save_dir.close(); - writeLevelDat(allocator, save_dir, trimmed, world.seed, world.generator_index, world.last_played) catch |err| { - log.log.warn("Failed to write level.dat for rename: {}", .{err}); - return; - }; + try writeLevelDat(allocator, save_dir, trimmed, world.seed, world.generator_index, world.last_played); allocator.free(self.worlds[idx].name); self.worlds[idx].name = new_name; self.confirm_rename = false; self.rename_buffer.clearRetainingCapacity(); + self.error_message = null; } fn clearAllWorlds(self: *@This()) !void { @@ -477,14 +488,18 @@ pub const WorldListScreen = struct { const new_worlds = try allocator.alloc(WorldEntry, 0); errdefer allocator.free(new_worlds); for (self.worlds) |e| { - deleteWorld(allocator, e.dir_path); + try deleteWorld(e.dir_path); + } + for (self.worlds) |e| { allocator.free(e.name); + if (e.dir_path.len > 0) allocator.free(e.dir_path); } allocator.free(self.worlds); self.worlds = new_worlds; self.selected = null; self.confirm_clear_all = false; self.scroll_offset = 0.0; + self.error_message = null; } }; diff --git a/modules/world-core/src/root.zig b/modules/world-core/src/root.zig index 2f7788a1..33868146 100644 --- a/modules/world-core/src/root.zig +++ b/modules/world-core/src/root.zig @@ -5,6 +5,7 @@ pub const chunk_constants = @import("chunk_constants.zig"); pub const chunk_key = @import("chunk_key.zig"); pub const light = @import("light.zig"); pub const lod_data = @import("lod_data.zig"); +pub const telemetry = @import("telemetry.zig"); pub const biome_and_block_tests = @import("biome_and_block_tests.zig"); pub const block_biome_tests = @import("block_biome_tests.zig"); pub const block_registry_tests = @import("block_registry_tests.zig"); @@ -59,3 +60,5 @@ pub const LODVerticalSpan = lod_data.LODVerticalSpan; pub const LODWaterState = lod_data.LODWaterState; pub const MAX_LOD_VERTICAL_SPANS = lod_data.MAX_LOD_VERTICAL_SPANS; pub const regionSizeBlocks = lod_data.regionSizeBlocks; +pub const ChunkStateCounts = telemetry.ChunkStateCounts; +pub const WorldStateData = telemetry.WorldStateData; diff --git a/modules/world-core/src/telemetry.zig b/modules/world-core/src/telemetry.zig new file mode 100644 index 00000000..8979fd0f --- /dev/null +++ b/modules/world-core/src/telemetry.zig @@ -0,0 +1,17 @@ +pub const ChunkStateCounts = struct { + total: u32 = 0, + missing: u32 = 0, + generating: u32 = 0, + meshing: u32 = 0, + renderable: u32 = 0, + other_states: u32 = 0, + dirty: u32 = 0, +}; + +pub const WorldStateData = struct { + generator_name: []const u8, + seed: u64, + gen_queue: u32, + mesh_queue: u32, + upload_queue: u32, +}; diff --git a/modules/world-persistence/src/save_manager.zig b/modules/world-persistence/src/save_manager.zig index c845c8e3..428fb8c9 100644 --- a/modules/world-persistence/src/save_manager.zig +++ b/modules/world-persistence/src/save_manager.zig @@ -64,6 +64,7 @@ pub const SaveManager = struct { failed_mutex: sync.Mutex, failed_chunks: std.ArrayListUnmanaged(ChunkKey), + failed_save_count: std.atomic.Value(usize), thread: std.Thread, @@ -100,6 +101,7 @@ pub const SaveManager = struct { .region_cache = .empty, .failed_mutex = .{}, .failed_chunks = .empty, + .failed_save_count = std.atomic.Value(usize).init(0), .level_data = blk: { const generator_copy = try allocator.dupe(u8, generator_name); errdefer allocator.free(generator_copy); @@ -129,6 +131,7 @@ pub const SaveManager = struct { self.level_data.touchLastPlayed(); self.level_data.saveToFile(self.allocator, self.save_dir) catch |err| { log.log.err("Failed to save level.dat: {}", .{err}); + self.recordSaveFailure(); }; self.queue.deinit(self.allocator); @@ -166,6 +169,7 @@ pub const SaveManager = struct { self.queue.append(self.allocator, snapshot) catch |err| { log.log.err("Failed to enqueue chunk ({}, {}) for save: {}", .{ snapshot.chunk_x, snapshot.chunk_z, err }); + self.recordSaveFailure(); }; } @@ -232,6 +236,14 @@ pub const SaveManager = struct { return failed; } + pub fn takeFailedSaveCount(self: *SaveManager) usize { + return self.failed_save_count.swap(0, .acq_rel); + } + + fn recordSaveFailure(self: *SaveManager) void { + _ = self.failed_save_count.fetchAdd(1, .monotonic); + } + fn saveThreadFn(self: *SaveManager) void { log.log.debug("Save thread started", .{}); @@ -284,8 +296,12 @@ pub const SaveManager = struct { for (batch[0..count]) |entry| { self.saveOneChunk(&entry) catch |err| { log.log.err("Failed to save chunk ({}, {}): {}", .{ entry.chunk_x, entry.chunk_z, err }); + self.recordSaveFailure(); self.failed_mutex.lock(); - self.failed_chunks.append(self.allocator, .{ .x = entry.chunk_x, .z = entry.chunk_z }) catch {}; + self.failed_chunks.append(self.allocator, .{ .x = entry.chunk_x, .z = entry.chunk_z }) catch |append_err| { + log.log.err("Failed to track failed chunk save ({}, {}): {}", .{ entry.chunk_x, entry.chunk_z, append_err }); + self.recordSaveFailure(); + }; self.failed_mutex.unlock(); }; } diff --git a/modules/world-runtime/src/chunk_queue_coordinator.zig b/modules/world-runtime/src/chunk_queue_coordinator.zig index 6091e28f..2adac8bc 100644 --- a/modules/world-runtime/src/chunk_queue_coordinator.zig +++ b/modules/world-runtime/src/chunk_queue_coordinator.zig @@ -275,7 +275,14 @@ pub const ChunkQueueCoordinator = struct { if (load_result == .read_error or load_result == .corrupt_data) { log.log.warn("Save load failed for chunk ({}, {}): {}, regenerating", .{ cx, cz, load_result }); } - self.generator.generate(&chunk_data.chunk, &self.gen_queue.abort_worker); + self.generator.generate(&chunk_data.chunk, &self.gen_queue.abort_worker) catch |err| { + log.log.warn("CHUNK_GEN_ERROR: ({},{}) generator failed: {}", .{ cx, cz, err }); + self.storage.chunks_mutex.lock(); + chunk_data.chunk.state = .missing; + chunk_data.chunk.generated = false; + self.storage.chunks_mutex.unlock(); + return; + }; if (self.gen_queue.abort_worker) { self.storage.chunks_mutex.lock(); chunk_data.chunk.state = .missing; diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index b03d3a31..790f05d3 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -39,7 +39,7 @@ const MAX_MDI_CHUNKS = @import("world_renderer.zig").MAX_MDI_CHUNKS; const RenderStats = @import("world_renderer.zig").RenderStats; const RenderLayer = @import("world_renderer.zig").RenderLayer; const ShadowStats = @import("world_renderer.zig").ShadowStats; -const ChunkStateCounts = @import("engine-ui").chunk_inspector_overlay.ChunkStateCounts; +const ChunkStateCounts = world_core.ChunkStateCounts; const VoxelCollisionWorld = @import("engine-physics").VoxelCollisionWorld; const GraphicsWorldRenderView = @import("engine-rhi").IWorldRenderView; const ILPVWorld = @import("engine-rhi").ILPVWorld; @@ -51,7 +51,7 @@ pub const DebugLightInfo = struct { block: u4, entrance_bounce: u4, }; -const WorldStateData = @import("engine-ui").chunk_inspector_overlay.WorldStateData; +const WorldStateData = world_core.WorldStateData; pub const GpuMeshDispatch = struct { dispatch_fn: ?*const fn (ctx: *anyopaque) void, dispatch_ctx: ?*anyopaque, @@ -646,6 +646,10 @@ pub const World = struct { defer dirty_keys.deinit(self.allocator); const failed = sm.flush(); + const failure_count = sm.takeFailedSaveCount(); + if (failure_count > 0) { + log.log.warn("{} save failure(s) occurred while saving modified chunks", .{failure_count}); + } self.remarkFailedSaves(failed); } @@ -658,6 +662,10 @@ pub const World = struct { const failed = sm.flush(); sm.markAutoSaved(); + const failure_count = sm.takeFailedSaveCount(); + if (failure_count > 0) { + log.log.warn("{} save failure(s) occurred during auto-save", .{failure_count}); + } self.remarkFailedSaves(failed); } diff --git a/modules/world-runtime/src/world_streamer.zig b/modules/world-runtime/src/world_streamer.zig index 1db9abf1..ee2f85f7 100644 --- a/modules/world-runtime/src/world_streamer.zig +++ b/modules/world-runtime/src/world_streamer.zig @@ -212,7 +212,11 @@ pub const WorldStreamer = struct { if (data.chunk.generated) continue; data.chunk.state = .generating; - self.generator.generate(&data.chunk, null); + self.generator.generate(&data.chunk, null) catch |err| { + log.log.warn("STARTUP_WARMUP_GEN_FAILED: ({},{}) {}", .{ cx, cz, err }); + data.chunk.state = .missing; + continue; + }; if (!data.chunk.generated) { data.chunk.state = .missing; continue; diff --git a/modules/worldgen-api/src/root.zig b/modules/worldgen-api/src/root.zig index b63ba88b..65032eaf 100644 --- a/modules/worldgen-api/src/root.zig +++ b/modules/worldgen-api/src/root.zig @@ -80,7 +80,7 @@ pub const Generator = struct { info: GeneratorInfo, pub const VTable = struct { - generate: *const fn (ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) void, + generate: *const fn (ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) WorldgenError!void, generateHeightmapOnly: *const fn (ptr: *anyopaque, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void, maybeRecenterCache: *const fn (ptr: *anyopaque, player_x: i32, player_z: i32) bool, getSeed: *const fn (ptr: *anyopaque) u64, @@ -89,8 +89,8 @@ pub const Generator = struct { deinit: *const fn (ptr: *anyopaque, allocator: std.mem.Allocator) void, }; - pub fn generate(self: Generator, chunk: *Chunk, stop_flag: ?*const bool) void { - self.vtable.generate(self.ptr, chunk, stop_flag); + pub fn generate(self: Generator, chunk: *Chunk, stop_flag: ?*const bool) WorldgenError!void { + try self.vtable.generate(self.ptr, chunk, stop_flag); } pub fn generateHeightmapOnly(self: Generator, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void { @@ -129,6 +129,10 @@ pub const RegistryError = error{ OutOfMemory, }; +pub const WorldgenError = error{ + OutOfMemory, +}; + pub const GeneratorDescriptor = struct { id: []const u8, aliases: []const []const u8 = &.{}, diff --git a/modules/worldgen-flat/src/root.zig b/modules/worldgen-flat/src/root.zig index 95af75a0..b066d190 100644 --- a/modules/worldgen-flat/src/root.zig +++ b/modules/worldgen-flat/src/root.zig @@ -31,7 +31,7 @@ pub const FlatWorldGenerator = struct { return .{ .seed = seed, .allocator = allocator }; } - pub fn generate(self: *FlatWorldGenerator, chunk: *Chunk, stop_flag: ?*const bool) void { + pub fn generate(self: *FlatWorldGenerator, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { chunk.generated = false; var local_z: u32 = 0; @@ -59,7 +59,7 @@ pub const FlatWorldGenerator = struct { } } - LightingComputer.computeSkylight(chunk, self.allocator) catch unreachable; + try LightingComputer.computeSkylight(chunk, self.allocator); chunk.generated = true; chunk.dirty = true; @@ -129,9 +129,9 @@ pub const FlatWorldGenerator = struct { .deinit = deinitWrapper, }; - fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) void { + fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { const self: *FlatWorldGenerator = @ptrCast(@alignCast(ptr)); - self.generate(chunk, stop_flag); + try self.generate(chunk, stop_flag); } fn generateHeightmapOnlyWrapper(ptr: *anyopaque, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void { diff --git a/modules/worldgen-overworld-v2/src/root.zig b/modules/worldgen-overworld-v2/src/root.zig index 4606413b..a5ddb200 100644 --- a/modules/worldgen-overworld-v2/src/root.zig +++ b/modules/worldgen-overworld-v2/src/root.zig @@ -124,7 +124,7 @@ pub const OverworldV2Generator = struct { _ = self; } - pub fn generate(self: *OverworldV2Generator, chunk: *Chunk, stop_flag: ?*const bool) void { + pub fn generate(self: *OverworldV2Generator, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { chunk.generated = false; @memset(&chunk.blocks, .air); @memset(&chunk.biomes, .plains); @@ -358,9 +358,9 @@ pub const OverworldV2Generator = struct { .deinit = deinitWrapper, }; - fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) void { + fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { const self: *OverworldV2Generator = @ptrCast(@alignCast(ptr)); - self.generate(chunk, stop_flag); + try self.generate(chunk, stop_flag); } fn generateHeightmapOnlyWrapper(ptr: *anyopaque, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void { @@ -427,7 +427,7 @@ test "overworld-v2 deterministic terrain columns" { test "overworld-v2 generates a chunk" { var gen = OverworldV2Generator.init(12345, std.testing.allocator); var chunk = Chunk.init(0, 0); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); try std.testing.expect(chunk.generated); try std.testing.expect(chunk.getBlock(0, 0, 0) == .bedrock); try std.testing.expect(chunk.getSurfaceHeight(8, 8) > 0); @@ -448,7 +448,7 @@ test "overworld-v2 places trees in forested chunks" { for (positions) |pos| { var chunk = Chunk.init(pos[0], pos[1]); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); for (chunk.blocks) |block| { if (trees.isTreeBlock(block)) tree_blocks += 1; } @@ -472,7 +472,7 @@ test "overworld-v2 places ground vegetation" { for (positions) |pos| { var chunk = Chunk.init(pos[0], pos[1]); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); for (chunk.blocks) |block| { if (vegetation.isVegetationBlock(block)) vegetation_blocks += 1; } diff --git a/modules/worldgen-overworld/src/overworld_generator.zig b/modules/worldgen-overworld/src/overworld_generator.zig index 67cbc417..3136e044 100644 --- a/modules/worldgen-overworld/src/overworld_generator.zig +++ b/modules/worldgen-overworld/src/overworld_generator.zig @@ -26,6 +26,7 @@ const ClassificationCache = gen_region.ClassificationCache; const gen_interface = @import("worldgen-api"); const Generator = gen_interface.Generator; const GeneratorInfo = gen_interface.GeneratorInfo; +const WorldgenError = gen_interface.WorldgenError; const ColumnInfo = gen_interface.ColumnInfo; const log = @import("engine-core").log; @@ -168,7 +169,7 @@ pub const OverworldGenerator = struct { return false; } - pub fn generate(self: *OverworldGenerator, chunk: *Chunk, stop_flag: ?*const bool) void { + pub fn generate(self: *OverworldGenerator, chunk: *Chunk, stop_flag: ?*const bool) WorldgenError!void { chunk.generated = false; const world_x = chunk.getWorldX(); const world_z = chunk.getWorldZ(); @@ -779,9 +780,9 @@ pub const OverworldGenerator = struct { .deinit = deinitWrapper, }; - fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) void { + fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) WorldgenError!void { const self: *OverworldGenerator = @ptrCast(@alignCast(ptr)); - self.generate(chunk, stop_flag); + try self.generate(chunk, stop_flag); } fn generateHeightmapOnlyWrapper(ptr: *anyopaque, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void { diff --git a/modules/worldgen-test/src/root.zig b/modules/worldgen-test/src/root.zig index eff19e6c..896cd6d5 100644 --- a/modules/worldgen-test/src/root.zig +++ b/modules/worldgen-test/src/root.zig @@ -38,7 +38,7 @@ pub const ShadowTestWorldGenerator = struct { return .{ .seed = seed, .allocator = allocator }; } - pub fn generate(self: *ShadowTestWorldGenerator, chunk: *Chunk, stop_flag: ?*const bool) void { + pub fn generate(self: *ShadowTestWorldGenerator, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { chunk.generated = false; var local_z: u32 = 0; @@ -56,8 +56,8 @@ pub const ShadowTestWorldGenerator = struct { } updateColumnMetadata(chunk); - LightingComputer.computeSkylight(chunk, self.allocator) catch unreachable; - LightingComputer.computeBlockLight(chunk, self.allocator) catch unreachable; + try LightingComputer.computeSkylight(chunk, self.allocator); + try LightingComputer.computeBlockLight(chunk, self.allocator); chunk.generated = true; chunk.dirty = true; @@ -226,9 +226,9 @@ pub const ShadowTestWorldGenerator = struct { .deinit = deinitWrapper, }; - fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) void { + fn generateWrapper(ptr: *anyopaque, chunk: *Chunk, stop_flag: ?*const bool) worldgen_api.WorldgenError!void { const self: *ShadowTestWorldGenerator = @ptrCast(@alignCast(ptr)); - self.generate(chunk, stop_flag); + try self.generate(chunk, stop_flag); } fn generateHeightmapOnlyWrapper(ptr: *anyopaque, data: *LODSimplifiedData, region_x: i32, region_z: i32, lod_level: LODLevel, stop_flag: ?*const std.atomic.Value(bool)) void { diff --git a/src/game/app.zig b/src/game/app.zig index 3d4513fb..2b955301 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -390,7 +390,19 @@ pub const App = struct { self.render_system.beginFrame(); errdefer self.render_system.endFrame(); - try self.render_system.updateGlobalUniforms(Mat4.identity, Vec3.zero, Vec3.init(0, -1, 0), Vec3.one, 0, Vec3.zero, 0, false, 1.0, 0.1, false, .{ + try self.render_system.updateGlobalUniforms(.{ + .view_proj = Mat4.identity, + .cam_pos = Vec3.zero, + .sun_dir = Vec3.init(0, -1, 0), + .sun_color = Vec3.one, + .time = 0, + .fog_color = Vec3.zero, + .fog_density = 0, + .fog_enabled = false, + .sun_intensity = 1.0, + .ambient = 0.1, + .use_texture = false, + }, .{ .cam_pos = Vec3.zero, .view_proj = Mat4.identity, .sun_dir = Vec3.init(0, -1, 0), diff --git a/src/worldgen_tests.zig b/src/worldgen_tests.zig index d8fb3010..a7fa2b3f 100644 --- a/src/worldgen_tests.zig +++ b/src/worldgen_tests.zig @@ -39,8 +39,8 @@ test "WorldGen same seed produces identical blocks at origin" { var chunk1 = Chunk.init(0, 0); var chunk2 = Chunk.init(0, 0); - gen1.generate(&chunk1, null); - gen2.generate(&chunk2, null); + try gen1.generate(&chunk1, null); + try gen2.generate(&chunk2, null); try testing.expectEqualSlices(BlockType, &chunk1.blocks, &chunk2.blocks); } @@ -56,8 +56,8 @@ test "WorldGen same seed produces identical biomes at origin" { var chunk1 = Chunk.init(0, 0); var chunk2 = Chunk.init(0, 0); - gen1.generate(&chunk1, null); - gen2.generate(&chunk2, null); + try gen1.generate(&chunk1, null); + try gen2.generate(&chunk2, null); try testing.expectEqualSlices(BiomeId, &chunk1.biomes, &chunk2.biomes); } @@ -73,9 +73,9 @@ test "WorldGen same seed produces identical blocks at different positions" { var chunk1b = Chunk.init(1, 0); var chunk1c = Chunk.init(0, 1); - gen1.generate(&chunk1a, null); - gen1.generate(&chunk1b, null); - gen1.generate(&chunk1c, null); + try gen1.generate(&chunk1a, null); + try gen1.generate(&chunk1b, null); + try gen1.generate(&chunk1c, null); var gen2 = OverworldGenerator.init(seed, allocator, deco_registry.StandardDecorationProvider.provider()); defer gen2.deinit(); @@ -83,9 +83,9 @@ test "WorldGen same seed produces identical blocks at different positions" { var chunk2b = Chunk.init(1, 0); var chunk2c = Chunk.init(0, 1); - gen2.generate(&chunk2a, null); - gen2.generate(&chunk2b, null); - gen2.generate(&chunk2c, null); + try gen2.generate(&chunk2a, null); + try gen2.generate(&chunk2b, null); + try gen2.generate(&chunk2c, null); try testing.expectEqualSlices(BlockType, &chunk1a.blocks, &chunk2a.blocks); try testing.expectEqualSlices(BlockType, &chunk1b.blocks, &chunk2b.blocks); @@ -106,8 +106,8 @@ test "WorldGen different seeds produce different blocks" { var chunk1 = Chunk.init(0, 0); var chunk2 = Chunk.init(0, 0); - gen1.generate(&chunk1, null); - gen2.generate(&chunk2, null); + try gen1.generate(&chunk1, null); + try gen2.generate(&chunk2, null); const all_same = std.mem.eql(BlockType, &chunk1.blocks, &chunk2.blocks); try testing.expect(!all_same); @@ -135,8 +135,8 @@ test "WorldGen different seeds produce different biomes" { var chunk1 = Chunk.init(loc[0], loc[1]); var chunk2 = Chunk.init(loc[0], loc[1]); - gen1.generate(&chunk1, null); - gen2.generate(&chunk2, null); + try gen1.generate(&chunk1, null); + try gen2.generate(&chunk2, null); if (!std.mem.eql(BiomeId, &chunk1.biomes, &chunk2.biomes)) { differences_found += 1; @@ -170,11 +170,11 @@ test "WorldGen determinism across multiple chunks with same seed" { }; for (&gens, 0..) |*gen, i| { - gen.generate(&chunks1[i], null); + try gen.generate(&chunks1[i], null); } for (&gens, 0..) |*gen, i| { - gen.generate(&chunks2[i], null); + try gen.generate(&chunks2[i], null); } for (0..3) |i| { @@ -190,7 +190,7 @@ test "WorldGen golden output for known seed at origin" { defer gen.deinit(); var chunk = Chunk.init(0, 0); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); try testing.expect(chunk.generated); try testing.expect(chunk.dirty); @@ -213,8 +213,8 @@ test "Overworld V2 generator is deterministic" { var chunk1 = Chunk.init(0, 0); var chunk2 = Chunk.init(0, 0); - gen1.generate(&chunk1, null); - gen2.generate(&chunk2, null); + try gen1.generate(&chunk1, null); + try gen2.generate(&chunk2, null); try testing.expectEqualSlices(BlockType, &chunk1.blocks, &chunk2.blocks); try testing.expectEqualSlices(BiomeId, &chunk1.biomes, &chunk2.biomes); @@ -239,7 +239,7 @@ test "Overworld V2 stable chunk fingerprints for known seed" { for (positions, 0..) |pos, i| { var chunk = Chunk.init(pos[0], pos[1]); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); const fp = chunkFingerprint(&chunk); try testing.expectEqual(expected[i], fp); } @@ -270,7 +270,7 @@ test "WorldGen stable chunk fingerprints for known seed" { for (positions, 0..) |pos, i| { var chunk = Chunk.init(pos[0], pos[1]); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); const fp = chunkFingerprint(&chunk); try testing.expectEqual(expected[i], fp); } @@ -282,7 +282,7 @@ test "WorldGen populates heightmap and biomes" { defer gen.deinit(); var chunk = Chunk.init(0, 0); - gen.generate(&chunk, null); + try gen.generate(&chunk, null); const h = chunk.getSurfaceHeight(8, 8); try testing.expect(h > 0); From e649c6fcc08ec8a8b79005bfa67f01bbc9ac54f9 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 7 Jul 2026 05:59:36 +0100 Subject: [PATCH 2/3] fix(audit): surface save failures across sessions --- modules/game-ui/src/screens/world.zig | 17 +++ .../world-persistence/src/save_manager.zig | 59 +++++++ modules/world-runtime/src/world.zig | 16 ++ modules/worldgen-flat/src/root.zig | 9 ++ modules/worldgen-test/src/root.zig | 9 ++ src/game/player_tests.zig | 144 ++++++++++++++++++ src/game/world_list_tests.zig | 18 +++ src/interface_mock_tests.zig | 6 + 8 files changed, 278 insertions(+) diff --git a/modules/game-ui/src/screens/world.zig b/modules/game-ui/src/screens/world.zig index c1b83ecc..7f06d71a 100644 --- a/modules/game-ui/src/screens/world.zig +++ b/modules/game-ui/src/screens/world.zig @@ -22,6 +22,7 @@ const FRUSTUM_VERTEX_COUNT = DebugFrustum.FRUSTUM_VERTEX_COUNT; const ChunkInspectorOverlay = @import("engine-ui").ChunkInspectorOverlay; const Font = @import("engine-ui").font; const Color = @import("engine-ui").Color; +const Rect = @import("engine-ui").Rect; const WorldStats = @import("engine-ui").WorldStats; const LODStatsDisplay = @import("engine-ui").LODStatsDisplay; const log = @import("engine-core").log; @@ -64,6 +65,7 @@ pub const WorldScreen = struct { startup_diagnostic_logged: bool = false, stable_shadow_sun_dir: Vec3 = Vec3.init(0.0, 1.0, 0.0), stable_shadow_sun_initialized: bool = false, + save_failure_warning_count: usize = 0, pub const vtable = IScreen.VTable{ .deinit = deinit, .update = update, @@ -90,6 +92,7 @@ pub const WorldScreen = struct { .startup_diagnostic_start = context.time.elapsed, .startup_diagnostic_start_frame = context.time.frame_count, .startup_diagnostic_logged = false, + .save_failure_warning_count = world.takeSaveFailureWarningCount(), }; settings_data.clearTerrainDebugViews(context.settings); render_system.getRHI().options().setShadowDebugChannel(@intFromEnum(settings_data.resolveShadowDebugChannel(context.settings))); @@ -114,6 +117,11 @@ pub const WorldScreen = struct { const benchmark_mode = ctx.benchmark_runner != null; const automated_capture = ctx.build_config.shadow_test_scene and ctx.build_config.screenshot_path.len > 0; + const save_failures = self.world.takeSaveFailureWarningCount(); + if (save_failures > 0) { + self.save_failure_warning_count += save_failures; + } + if (!benchmark_mode and !automated_capture) { if (try self.processControls(now)) return; } @@ -335,6 +343,15 @@ pub const WorldScreen = struct { const shadow_sun_dir = if (shadow_sandbox_active) self.resolveStableShadowSunDir(render_sun_dir) else render_sun_dir; if (!shadow_sandbox_active) self.stable_shadow_sun_initialized = false; + if (self.save_failure_warning_count > 0) { + var save_warning_buf: [96]u8 = undefined; + const save_warning = std.fmt.bufPrint(&save_warning_buf, "SAVE WARNING: {} save failure(s). Check logs.", .{self.save_failure_warning_count}) catch "SAVE WARNING: save failures. Check logs."; + const warning_rect = Rect{ .x = 14.0 * ctx.settings.ui_scale, .y = 14.0 * ctx.settings.ui_scale, .width = 360.0 * ctx.settings.ui_scale, .height = 34.0 * ctx.settings.ui_scale }; + ui.drawRect(warning_rect, Color.rgba(0.18, 0.04, 0.05, 0.88)); + ui.drawRectOutline(warning_rect, Color.rgba(0.78, 0.30, 0.34, 1.0), 1.0 * ctx.settings.ui_scale); + Font.drawText(ui, save_warning, warning_rect.x + 10.0 * ctx.settings.ui_scale, warning_rect.y + 10.0 * ctx.settings.ui_scale, 0.78 * ctx.settings.ui_scale, Color.rgba(1.0, 0.90, 0.84, 1.0)); + } + // TODO: Replace this stabilization toggle with a user-facing simple lighting setting. const simple_lighting_mode = true; const lpv_quality = resolveLPVQuality(ctx.settings.lpv_quality_preset); diff --git a/modules/world-persistence/src/save_manager.zig b/modules/world-persistence/src/save_manager.zig index 428fb8c9..3a1c8bd6 100644 --- a/modules/world-persistence/src/save_manager.zig +++ b/modules/world-persistence/src/save_manager.zig @@ -27,6 +27,7 @@ const CHUNK_SIZE_Z = world_core.CHUNK_SIZE_Z; const SAVE_THREAD_INTERVAL_NS: u64 = 25 * std.time.ns_per_ms; const AUTO_SAVE_INTERVAL_MS: i64 = 60_000; const MAX_OPEN_REGIONS: usize = 16; +const SAVE_FAILURE_COUNT_FILE = "save_failures.dat"; pub const LoadResult = enum { success, @@ -65,6 +66,7 @@ pub const SaveManager = struct { failed_mutex: sync.Mutex, failed_chunks: std.ArrayListUnmanaged(ChunkKey), failed_save_count: std.atomic.Value(usize), + persisted_failed_save_count: std.atomic.Value(usize), thread: std.Thread, @@ -87,6 +89,8 @@ pub const SaveManager = struct { const path_copy = try allocator.dupe(u8, save_dir_path); errdefer allocator.free(path_copy); + const persisted_failures = loadPersistedSaveFailureCount(allocator, dir); + sm.* = .{ .allocator = allocator, .save_dir = dir, @@ -102,6 +106,7 @@ pub const SaveManager = struct { .failed_mutex = .{}, .failed_chunks = .empty, .failed_save_count = std.atomic.Value(usize).init(0), + .persisted_failed_save_count = std.atomic.Value(usize).init(persisted_failures), .level_data = blk: { const generator_copy = try allocator.dupe(u8, generator_name); errdefer allocator.free(generator_copy); @@ -240,8 +245,22 @@ pub const SaveManager = struct { return self.failed_save_count.swap(0, .acq_rel); } + pub fn takePersistedFailedSaveCount(self: *SaveManager) usize { + const count = self.persisted_failed_save_count.swap(0, .acq_rel); + if (count > 0) { + self.save_dir.deleteFile(SAVE_FAILURE_COUNT_FILE) catch |err| { + log.log.warn("Failed to clear persisted save failure count: {}", .{err}); + }; + } + return count; + } + fn recordSaveFailure(self: *SaveManager) void { _ = self.failed_save_count.fetchAdd(1, .monotonic); + const persisted_count = self.persisted_failed_save_count.fetchAdd(1, .monotonic) + 1; + persistSaveFailureCount(self.save_dir, persisted_count) catch |err| { + log.log.err("Failed to persist save failure count: {}", .{err}); + }; } fn saveThreadFn(self: *SaveManager) void { @@ -410,6 +429,20 @@ pub const SaveManager = struct { } }; +fn loadPersistedSaveFailureCount(allocator: Allocator, save_dir: fs.Dir) usize { + const content = save_dir.readFileAlloc(SAVE_FAILURE_COUNT_FILE, allocator, 128) catch return 0; + defer allocator.free(content); + return std.fmt.parseInt(usize, std.mem.trim(u8, content, " \t\r\n"), 10) catch 0; +} + +fn persistSaveFailureCount(save_dir: fs.Dir, count: usize) !void { + var buf: [32]u8 = undefined; + const text = try std.fmt.bufPrint(&buf, "{}\n", .{count}); + const file = try save_dir.createFile(SAVE_FAILURE_COUNT_FILE, .{}); + defer file.close(); + try file.writeAll(text); +} + const testing = std.testing; test "SaveManager init creates save directory and level.dat" { @@ -517,3 +550,29 @@ test "SaveManager duplicate enqueue overwrites previous" { try testing.expect(sm.loadChunk(0, 0, &loaded) == .success); try testing.expectEqual(BlockType.gold_ore, loaded.getBlock(5, 5, 5)); } + +test "SaveManager persists and consumes save failure count" { + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const dir = fs.Dir{ .inner = tmp_dir.dir }; + + var path_buf: [fs.max_path_bytes]u8 = undefined; + const base_path = try dir.realpath(".", &path_buf); + + var save_path_buf: [fs.max_path_bytes]u8 = undefined; + const save_path = try std.fmt.bufPrint(&save_path_buf, "{s}/test_failures", .{base_path}); + + { + var sm = try SaveManager.init(testing.allocator, save_path, "test_failures", 0, "flat"); + sm.recordSaveFailure(); + try testing.expectEqual(@as(usize, 1), sm.takeFailedSaveCount()); + sm.deinit(); + } + + var sm = try SaveManager.init(testing.allocator, save_path, "test_failures", 0, "flat"); + defer sm.deinit(); + + try testing.expectEqual(@as(usize, 1), sm.takePersistedFailedSaveCount()); + try testing.expectEqual(@as(usize, 0), sm.takePersistedFailedSaveCount()); +} diff --git a/modules/world-runtime/src/world.zig b/modules/world-runtime/src/world.zig index 790f05d3..8cc93285 100644 --- a/modules/world-runtime/src/world.zig +++ b/modules/world-runtime/src/world.zig @@ -113,6 +113,7 @@ pub const IWorld = struct { isLODEnabled: *const fn (ptr: *anyopaque) bool, shadowScene: *const fn (ptr: *anyopaque) IShadowScene, enableSaveManager: *const fn (ptr: *anyopaque, save_dir_path: []const u8, world_name: []const u8) anyerror!void, + takeSaveFailureWarningCount: *const fn (ptr: *anyopaque) usize, pauseGeneration: *const fn (ptr: *anyopaque) void, isPaused: *const fn (ptr: *anyopaque) bool, collisionWorld: *const fn (ptr: *anyopaque) VoxelCollisionWorld, @@ -181,6 +182,10 @@ pub const IWorld = struct { try self.vtable.enableSaveManager(self.ptr, save_dir_path, world_name); } + pub fn takeSaveFailureWarningCount(self: IWorld) usize { + return self.vtable.takeSaveFailureWarningCount(self.ptr); + } + pub fn pauseGeneration(self: IWorld) void { self.vtable.pauseGeneration(self.ptr); } @@ -605,6 +610,11 @@ pub const World = struct { } } + pub fn takeSaveFailureWarningCount(self: *World) usize { + const sm = self.save_manager orelse return 0; + return sm.takePersistedFailedSaveCount(); + } + fn enqueueModifiedChunks(self: *World, sm: *SaveManager) std.ArrayListUnmanaged(ChunkKey) { var dirty_keys = std.ArrayListUnmanaged(ChunkKey).empty; @@ -896,6 +906,7 @@ pub const World = struct { .isLODEnabled = iisLODEnabled, .shadowScene = ishadowScene, .enableSaveManager = ienableSaveManager, + .takeSaveFailureWarningCount = itakeSaveFailureWarningCount, .pauseGeneration = ipauseGeneration, .isPaused = iisPaused, .collisionWorld = icollisionWorld, @@ -981,6 +992,11 @@ pub const World = struct { try self.enableSaveManager(save_dir_path, world_name); } + fn itakeSaveFailureWarningCount(ptr: *anyopaque) usize { + const self: *World = @ptrCast(@alignCast(ptr)); + return self.takeSaveFailureWarningCount(); + } + fn ipauseGeneration(ptr: *anyopaque) void { const self: *World = @ptrCast(@alignCast(ptr)); self.pauseGeneration(); diff --git a/modules/worldgen-flat/src/root.zig b/modules/worldgen-flat/src/root.zig index b066d190..d77986aa 100644 --- a/modules/worldgen-flat/src/root.zig +++ b/modules/worldgen-flat/src/root.zig @@ -171,6 +171,15 @@ pub fn create(context: worldgen_api.CreateContext) worldgen_api.RegistryError!Ge return gen.generator(); } +test "FlatWorldGenerator propagates lighting allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + var gen = FlatWorldGenerator.init(0, failing.allocator()); + var chunk = Chunk.init(0, 0); + + try std.testing.expectError(error.OutOfMemory, gen.generate(&chunk, null)); + try std.testing.expect(!chunk.generated); +} + pub const descriptor = worldgen_api.GeneratorDescriptor{ .id = "zigcraft:flat", .aliases = &.{"flat"}, diff --git a/modules/worldgen-test/src/root.zig b/modules/worldgen-test/src/root.zig index 896cd6d5..faae4042 100644 --- a/modules/worldgen-test/src/root.zig +++ b/modules/worldgen-test/src/root.zig @@ -268,6 +268,15 @@ pub fn create(context: worldgen_api.CreateContext) worldgen_api.RegistryError!Ge return gen.generator(); } +test "ShadowTestWorldGenerator propagates lighting allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + var gen = ShadowTestWorldGenerator.init(0, failing.allocator()); + var chunk = Chunk.init(0, 0); + + try std.testing.expectError(error.OutOfMemory, gen.generate(&chunk, null)); + try std.testing.expect(!chunk.generated); +} + pub const descriptor = worldgen_api.GeneratorDescriptor{ .id = "zigcraft:shadow-test", .aliases = &.{ "test", "shadow-test", "lighting-test" }, diff --git a/src/game/player_tests.zig b/src/game/player_tests.zig index a71f532c..1dd0cd04 100644 --- a/src/game/player_tests.zig +++ b/src/game/player_tests.zig @@ -3,6 +3,11 @@ const testing = std.testing; const Vec3 = @import("zig-math").Vec3; const player_module = @import("game-core").player; const Player = player_module.Player; +const world_runtime = @import("world-runtime"); +const IWorld = world_runtime.IWorld; +const IWorldSimulation = world_runtime.IWorldSimulation; +const BlockType = @import("world-core").BlockType; +const Face = @import("world-core").Face; test "Player.init creates player with correct initial state" { const spawn_pos = Vec3.init(10, 100, 20); @@ -173,3 +178,142 @@ test "Player constants are reasonable values" { // Reach distance should be positive try testing.expect(Player.REACH_DISTANCE > 0.0); } + +test "Player block mutation errors are handled" { + const FailingWorld = struct { + set_block_calls: usize = 0, + + const VTABLE = IWorld.VTable{ + .update = update, + .render = render, + .renderOpaque = render, + .renderFluid = render, + .deinit = deinit, + .getRenderStats = getRenderStats, + .getStats = getStats, + .getLODStats = getLODStats, + .isLODEnabled = isLODEnabled, + .shadowScene = shadowScene, + .enableSaveManager = enableSaveManager, + .takeSaveFailureWarningCount = takeSaveFailureWarningCount, + .pauseGeneration = pauseGeneration, + .isPaused = isPaused, + .collisionWorld = collisionWorld, + .getBlock = getBlock, + .setBlock = setBlock, + .getColumnInfo = getColumnInfo, + .getDebugLightInfo = getDebugLightInfo, + .getRegionInfo = getRegionInfo, + .getGenerator = getGenerator, + .getGeneratorName = getGeneratorName, + .getRenderDistance = getRenderDistance, + .setRenderDistance = setRenderDistance, + .getHorizonDistance = getHorizonDistance, + .setHorizonDistance = setHorizonDistance, + .isLODRenderingEnabled = isLODRenderingEnabled, + .toggleLODRendering = toggleLODRendering, + .getChunkStateCounts = getChunkStateCounts, + .isStartupBusy = isStartupBusy, + .getWorldStateData = getWorldStateData, + .lpvWorld = lpvWorld, + .graphicsRenderView = graphicsRenderView, + .getGpuMeshDispatch = getGpuMeshDispatch, + }; + + fn interface(self: *@This()) IWorldSimulation { + return .{ .world = .{ .ptr = self, .vtable = &VTABLE } }; + } + + fn update(_: *anyopaque, _: Vec3, _: f32) anyerror!void {} + fn render(_: *anyopaque, _: @import("engine-math").Mat4, _: Vec3, _: bool) void {} + fn deinit(_: *anyopaque) void {} + fn getRenderStats(_: *anyopaque) @import("world-runtime").RenderStats { + return .{}; + } + fn getStats(_: *anyopaque) @import("world-runtime").WorldStatsData { + return .{ .chunks_loaded = 0, .total_vertices = 0, .gen_queue = 0, .mesh_queue = 0, .upload_queue = 0 }; + } + fn getLODStats(_: *anyopaque) ?@import("world-lod").LODStats { + return null; + } + fn isLODEnabled(_: *anyopaque) bool { + return false; + } + fn shadowScene(_: *anyopaque) @import("engine-rhi").IShadowScene { + return undefined; + } + fn enableSaveManager(_: *anyopaque, _: []const u8, _: []const u8) anyerror!void {} + fn takeSaveFailureWarningCount(_: *anyopaque) usize { + return 0; + } + fn pauseGeneration(_: *anyopaque) void {} + fn isPaused(_: *anyopaque) bool { + return false; + } + fn collisionWorld(_: *anyopaque) @import("engine-physics").VoxelCollisionWorld { + return undefined; + } + fn getBlock(_: *anyopaque, _: i32, _: i32, _: i32) BlockType { + return .stone; + } + fn setBlock(ptr: *anyopaque, _: i32, _: i32, _: i32, _: BlockType) anyerror!void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.set_block_calls += 1; + return error.TestExpectedError; + } + fn getColumnInfo(_: *anyopaque, _: i32, _: i32) @import("world-worldgen").ColumnInfo { + return undefined; + } + fn getDebugLightInfo(_: *anyopaque, _: i32, _: i32, _: i32) ?@import("world-runtime").DebugLightInfo { + return null; + } + fn getRegionInfo(_: *anyopaque, _: i32, _: i32) @import("world-worldgen").RegionInfo { + return undefined; + } + fn getGenerator(_: *anyopaque) @import("world-worldgen").Generator { + return undefined; + } + fn getGeneratorName(_: *anyopaque) []const u8 { + return "failing"; + } + fn getRenderDistance(_: *anyopaque) i32 { + return 0; + } + fn setRenderDistance(_: *anyopaque, _: i32) void {} + fn getHorizonDistance(_: *anyopaque) i32 { + return 0; + } + fn setHorizonDistance(_: *anyopaque, _: i32) void {} + fn isLODRenderingEnabled(_: *anyopaque) bool { + return false; + } + fn toggleLODRendering(_: *anyopaque) bool { + return false; + } + fn getChunkStateCounts(_: *anyopaque) @import("world-core").ChunkStateCounts { + return .{}; + } + fn isStartupBusy(_: *anyopaque) bool { + return false; + } + fn getWorldStateData(_: *anyopaque) @import("world-core").WorldStateData { + return .{ .generator_name = "failing", .seed = 0, .gen_queue = 0, .mesh_queue = 0, .upload_queue = 0 }; + } + fn lpvWorld(_: *anyopaque) @import("engine-rhi").ILPVWorld { + return undefined; + } + fn graphicsRenderView(ptr: *anyopaque) @import("engine-rhi").IWorldRenderView { + return .{ .ptr = ptr, .vtable = &.{ .render = render, .renderOpaque = render, .renderFluid = render } }; + } + fn getGpuMeshDispatch(_: *anyopaque) @import("world-runtime").GpuMeshDispatch { + return .{ .dispatch_fn = null, .dispatch_ctx = null }; + } + }; + + var world = FailingWorld{}; + var player = Player.init(Vec3.init(0, 10, 0), true); + player.target_block = .{ .x = 1, .y = 2, .z = 3, .face = Face.east, .distance = 1.0 }; + + player.breakTargetBlock(world.interface()); + try testing.expectEqual(@as(usize, 1), world.set_block_calls); +} diff --git a/src/game/world_list_tests.zig b/src/game/world_list_tests.zig index 691bf95c..5c82e789 100644 --- a/src/game/world_list_tests.zig +++ b/src/game/world_list_tests.zig @@ -130,3 +130,21 @@ test "scanWorlds keeps directory fallback when level.dat is missing" { try testing.expectEqual(@as(u64, 0), worlds[0].seed); try testing.expectEqual(@as(usize, 0), worlds[0].generator_index); } + +test "deleteWorld removes directory and reports invalid paths" { + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const dir = fs.Dir{ .inner = tmp_dir.dir }; + + try dir.makePath("victim"); + + var path_buf: [fs.max_path_bytes]u8 = undefined; + const base_path = try dir.realpath(".", &path_buf); + + var victim_path_buf: [fs.max_path_bytes]u8 = undefined; + const victim_path = try std.fmt.bufPrint(&victim_path_buf, "{s}/victim", .{base_path}); + + try world_list.deleteWorld(victim_path); + try testing.expectError(error.FileNotFound, dir.openDir("victim", .{})); + try testing.expectError(error.InvalidSavePath, world_list.deleteWorld("victim")); +} diff --git a/src/interface_mock_tests.zig b/src/interface_mock_tests.zig index 462cad8d..4bcf45b3 100644 --- a/src/interface_mock_tests.zig +++ b/src/interface_mock_tests.zig @@ -240,6 +240,7 @@ const MockWorld = struct { .isLODEnabled = isLODEnabled, .shadowScene = shadowScene, .enableSaveManager = enableSaveManager, + .takeSaveFailureWarningCount = takeSaveFailureWarningCount, .pauseGeneration = pauseGeneration, .isPaused = isPaused, .collisionWorld = collisionWorld, @@ -340,6 +341,11 @@ const MockWorld = struct { _ = world_name; } + fn takeSaveFailureWarningCount(ptr: *anyopaque) usize { + _ = ptr; + return 0; + } + fn pauseGeneration(ptr: *anyopaque) void { _ = ptr; } From b2f5ae1e0550b06b99187ac161c216efbb8612b2 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 7 Jul 2026 06:22:40 +0100 Subject: [PATCH 3/3] fix(worldgen): propagate overworld generation failures --- .../world-persistence/src/save_manager.zig | 11 ++++- modules/worldgen-overworld-v2/src/root.zig | 11 ++++- .../src/overworld_generator.zig | 44 +++++++++++++++++-- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/modules/world-persistence/src/save_manager.zig b/modules/world-persistence/src/save_manager.zig index 3a1c8bd6..d00f37ff 100644 --- a/modules/world-persistence/src/save_manager.zig +++ b/modules/world-persistence/src/save_manager.zig @@ -67,6 +67,7 @@ pub const SaveManager = struct { failed_chunks: std.ArrayListUnmanaged(ChunkKey), failed_save_count: std.atomic.Value(usize), persisted_failed_save_count: std.atomic.Value(usize), + persisted_failed_save_mutex: sync.Mutex, thread: std.Thread, @@ -107,6 +108,7 @@ pub const SaveManager = struct { .failed_chunks = .empty, .failed_save_count = std.atomic.Value(usize).init(0), .persisted_failed_save_count = std.atomic.Value(usize).init(persisted_failures), + .persisted_failed_save_mutex = .{}, .level_data = blk: { const generator_copy = try allocator.dupe(u8, generator_name); errdefer allocator.free(generator_copy); @@ -246,6 +248,9 @@ pub const SaveManager = struct { } pub fn takePersistedFailedSaveCount(self: *SaveManager) usize { + self.persisted_failed_save_mutex.lock(); + defer self.persisted_failed_save_mutex.unlock(); + const count = self.persisted_failed_save_count.swap(0, .acq_rel); if (count > 0) { self.save_dir.deleteFile(SAVE_FAILURE_COUNT_FILE) catch |err| { @@ -257,7 +262,11 @@ pub const SaveManager = struct { fn recordSaveFailure(self: *SaveManager) void { _ = self.failed_save_count.fetchAdd(1, .monotonic); - const persisted_count = self.persisted_failed_save_count.fetchAdd(1, .monotonic) + 1; + self.persisted_failed_save_mutex.lock(); + defer self.persisted_failed_save_mutex.unlock(); + + const persisted_count = self.persisted_failed_save_count.load(.acquire) + 1; + self.persisted_failed_save_count.store(persisted_count, .release); persistSaveFailureCount(self.save_dir, persisted_count) catch |err| { log.log.err("Failed to persist save failure count: {}", .{err}); }; diff --git a/modules/worldgen-overworld-v2/src/root.zig b/modules/worldgen-overworld-v2/src/root.zig index a5ddb200..726b75e2 100644 --- a/modules/worldgen-overworld-v2/src/root.zig +++ b/modules/worldgen-overworld-v2/src/root.zig @@ -156,7 +156,7 @@ pub const OverworldV2Generator = struct { self.placeVegetation(chunk, stop_flag); if (self.params.enable_lighting) { - LightingComputer.computeSkylight(chunk, self.allocator) catch return; + try LightingComputer.computeSkylight(chunk, self.allocator); } chunk.generated = true; @@ -511,3 +511,12 @@ test "overworld-v2 LOD tree density covers forest variants" { try std.testing.expectEqual(TreeShape.spruce, trees.defaultTreeShapeForBiome(.old_growth_taiga)); try std.testing.expectEqual(TreeShape.jungle, trees.defaultTreeShapeForBiome(.bamboo_jungle)); } + +test "overworld-v2 propagates lighting allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + var gen = OverworldV2Generator.init(0, failing.allocator()); + var chunk = Chunk.init(0, 0); + + try std.testing.expectError(error.OutOfMemory, gen.generate(&chunk, null)); + try std.testing.expect(!chunk.generated); +} diff --git a/modules/worldgen-overworld/src/overworld_generator.zig b/modules/worldgen-overworld/src/overworld_generator.zig index 3136e044..102f48c0 100644 --- a/modules/worldgen-overworld/src/overworld_generator.zig +++ b/modules/worldgen-overworld/src/overworld_generator.zig @@ -186,7 +186,7 @@ pub const OverworldGenerator = struct { break :blk .{ .x = self.cache_center_x, .z = self.cache_center_z }; }; - const phase_data = self.allocator.create(terrain_shape_mod.ChunkPhaseData) catch return; + const phase_data = try self.allocator.create(terrain_shape_mod.ChunkPhaseData); defer self.allocator.destroy(phase_data); if (!self.terrain_shape.prepareChunkPhaseData( phase_data, @@ -230,13 +230,13 @@ pub const OverworldGenerator = struct { } LightingComputer.computeSkylight(chunk, self.allocator) catch |err| { log.log.errWithTrace("Failed to compute skylight for chunk ({}, {}): {}", .{ chunk.chunk_x, chunk.chunk_z, err }); - return; + return err; }; if (stop_flag) |sf| if (sf.*) return; if (!self.basic_chunks_only) { LightingComputer.computeBlockLight(chunk, self.allocator) catch |err| { log.log.errWithTrace("Failed to compute block light for chunk ({}, {}): {}", .{ chunk.chunk_x, chunk.chunk_z, err }); - return; + return err; }; } @@ -821,3 +821,41 @@ test "LOD cached water surfaces resolve to seabed block" { try std.testing.expectEqual(BlockType.water, OverworldGenerator.surfaceTypeToBlock(undefined, .water_shallow)); try std.testing.expectEqual(BlockType.water, OverworldGenerator.surfaceTypeToBlock(undefined, .water_deep)); } + +fn testDecorationProvider() DecorationProvider { + const NoopProvider = struct { + fn decorate(_: ?*anyopaque, _: DecorationProvider.DecorationContext) void {} + + const VTABLE = DecorationProvider.VTable{ + .decorate = decorate, + }; + }; + + return .{ .ptr = null, .vtable = &NoopProvider.VTABLE }; +} + +test "OverworldGenerator propagates phase allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); + var gen = OverworldGenerator.initWithParams(0, failing.allocator(), testDecorationProvider(), .{ + .terrain_shape = .{ .disable_caves = true }, + .basic_chunks_only = true, + }); + defer gen.deinit(); + + var chunk = Chunk.init(0, 0); + try std.testing.expectError(error.OutOfMemory, gen.generate(&chunk, null)); + try std.testing.expect(!chunk.generated); +} + +test "OverworldGenerator propagates lighting allocation failure" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 1 }); + var gen = OverworldGenerator.initWithParams(0, failing.allocator(), testDecorationProvider(), .{ + .terrain_shape = .{ .disable_caves = true }, + .basic_chunks_only = true, + }); + defer gen.deinit(); + + var chunk = Chunk.init(0, 0); + try std.testing.expectError(error.OutOfMemory, gen.generate(&chunk, null)); + try std.testing.expect(!chunk.generated); +}