diff --git a/config/seed_runtime.json b/config/seed_runtime.json index ea01fb1..25084f7 100644 --- a/config/seed_runtime.json +++ b/config/seed_runtime.json @@ -1,120 +1,137 @@ { + "schema_version": 2, "launcher": { "name": "Cube Demo", "description": "3D cube room with first-person controls, lanterns, and physics interactions", "enabled": true }, - "window_width": 1024, - "window_height": 768, - "lua_script": "scripts/cube_logic.lua", - "scripts_directory": "scripts", - "mouse_grab": { - "enabled": true, - "grab_on_click": true, - "release_on_escape": true, - "start_grabbed": false, - "hide_cursor": true, - "relative_mode": true, - "grab_mouse_button": "left", - "release_key": "escape" - }, - "input_bindings": { - "move_forward": "W", - "move_back": "S", - "move_left": "A", - "move_right": "D", - "fly_up": "Q", - "fly_down": "Z", - "jump": "Space", - "noclip_toggle": "N", - "music_toggle": "M", - "music_toggle_gamepad": "start", - "gamepad_move_x_axis": "leftx", - "gamepad_move_y_axis": "lefty", - "gamepad_look_x_axis": "rightx", - "gamepad_look_y_axis": "righty", - "gamepad_dpad_up": "dpup", - "gamepad_dpad_down": "dpdown", - "gamepad_dpad_left": "dpleft", - "gamepad_dpad_right": "dpright", - "gamepad_button_actions": { - "a": "gamepad_a", - "b": "gamepad_b", - "x": "gamepad_x", - "y": "gamepad_y", - "leftshoulder": "gamepad_lb", - "rightshoulder": "gamepad_rb", - "leftstick": "gamepad_ls", - "rightstick": "gamepad_rs", - "back": "gamepad_back", - "start": "gamepad_start" + "window": { + "title": "SDL3 Bgfx Demo", + "size": { + "width": 1024, + "height": 768 }, - "gamepad_axis_actions": { - "lefttrigger": "gamepad_lt", - "righttrigger": "gamepad_rt" - }, - "gamepad_axis_action_threshold": 0.5 - }, - "project_root": "../", - "shaders_directory": "shaders", - "bgfx": { - "renderer": "vulkan" - }, - "materialx": { - "enabled": true, - "parameters_enabled": true, - "library_path": "MaterialX/libraries", - "library_folders": [ - "stdlib", - "pbrlib", - "lights", - "bxdf", - "cmlib", - "nprlib", - "targets" - ] - }, - "materialx_materials": [ - { - "shader_key": "floor", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_wood_tiled.mtlx", - "material": "Tiled_Wood" - }, - { - "shader_key": "wall", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brick_procedural.mtlx", - "material": "M_BrickPattern" - }, - { - "shader_key": "ceiling", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx", - "material": "Marble_3D" - }, - { - "shader_key": "solid", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brass_tiled.mtlx", - "material": "Tiled_Brass" + "mouse_grab": { + "enabled": true, + "grab_on_click": true, + "release_on_escape": true, + "start_grabbed": false, + "hide_cursor": true, + "relative_mode": true, + "grab_mouse_button": "left", + "release_key": "escape" } - ], - "atmospherics": { - "ambient_strength": 0.006, - "fog_density": 0.006, - "fog_color": [0.03, 0.04, 0.06], - "sky_color": [0.02, 0.03, 0.05], - "gamma": 2.2, - "exposure": 1.15, - "enable_tone_mapping": true, - "enable_shadows": true, - "enable_ssgi": true, - "enable_volumetric_lighting": true, - "pbr_roughness": 0.28, - "pbr_metallic": 0.08 }, - "gui_font": { - "use_freetype": true, - "font_path": "scripts/assets/fonts/Roboto-Regular.ttf", - "font_size": 18.0 + "scripts": { + "entry": "scripts/cube_logic.lua", + "lua_debug": false + }, + "paths": { + "project_root": "../", + "scripts": "scripts", + "shaders": "shaders" + }, + "input": { + "bindings": { + "move_forward": "W", + "move_back": "S", + "move_left": "A", + "move_right": "D", + "fly_up": "Q", + "fly_down": "Z", + "jump": "Space", + "noclip_toggle": "N", + "music_toggle": "M", + "music_toggle_gamepad": "start", + "gamepad_move_x_axis": "leftx", + "gamepad_move_y_axis": "lefty", + "gamepad_look_x_axis": "rightx", + "gamepad_look_y_axis": "righty", + "gamepad_dpad_up": "dpup", + "gamepad_dpad_down": "dpdown", + "gamepad_dpad_left": "dpleft", + "gamepad_dpad_right": "dpright", + "gamepad_button_actions": { + "a": "gamepad_a", + "b": "gamepad_b", + "x": "gamepad_x", + "y": "gamepad_y", + "leftshoulder": "gamepad_lb", + "rightshoulder": "gamepad_rb", + "leftstick": "gamepad_ls", + "rightstick": "gamepad_rs", + "back": "gamepad_back", + "start": "gamepad_start" + }, + "gamepad_axis_actions": { + "lefttrigger": "gamepad_lt", + "righttrigger": "gamepad_rt" + }, + "gamepad_axis_action_threshold": 0.5 + } + }, + "rendering": { + "bgfx": { + "renderer": "vulkan" + }, + "materialx": { + "enabled": true, + "parameters_enabled": true, + "library_path": "MaterialX/libraries", + "library_folders": [ + "stdlib", + "pbrlib", + "lights", + "bxdf", + "cmlib", + "nprlib", + "targets" + ], + "materials": [ + { + "shader_key": "floor", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_wood_tiled.mtlx", + "material": "Tiled_Wood" + }, + { + "shader_key": "wall", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brick_procedural.mtlx", + "material": "M_BrickPattern" + }, + { + "shader_key": "ceiling", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx", + "material": "Marble_3D" + }, + { + "shader_key": "solid", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brass_tiled.mtlx", + "material": "Tiled_Brass" + } + ] + }, + "atmospherics": { + "ambient_strength": 0.006, + "fog_density": 0.006, + "fog_color": [0.03, 0.04, 0.06], + "sky_color": [0.02, 0.03, 0.05], + "gamma": 2.2, + "exposure": 1.15, + "enable_tone_mapping": true, + "enable_shadows": true, + "enable_ssgi": true, + "enable_volumetric_lighting": true, + "pbr_roughness": 0.28, + "pbr_metallic": 0.08 + } + }, + "gui": { + "font": { + "use_freetype": true, + "font_path": "scripts/assets/fonts/Roboto-Regular.ttf", + "font_size": 18.0 + }, + "opacity": 1.0 }, - "gui_opacity": 1.0, "config_file": "config/seed_runtime.json" } diff --git a/config/seed_runtime_opengl.json b/config/seed_runtime_opengl.json index 57cc7e1..ca948c4 100644 --- a/config/seed_runtime_opengl.json +++ b/config/seed_runtime_opengl.json @@ -1,120 +1,137 @@ { + "schema_version": 2, "launcher": { "name": "Cube Demo", "description": "3D cube room with first-person controls, lanterns, and physics interactions", "enabled": true }, - "window_width": 1024, - "window_height": 768, - "lua_script": "scripts/cube_logic.lua", - "scripts_directory": "scripts", - "mouse_grab": { - "enabled": true, - "grab_on_click": true, - "release_on_escape": true, - "start_grabbed": false, - "hide_cursor": true, - "relative_mode": true, - "grab_mouse_button": "left", - "release_key": "escape" - }, - "input_bindings": { - "move_forward": "W", - "move_back": "S", - "move_left": "A", - "move_right": "D", - "fly_up": "Q", - "fly_down": "Z", - "jump": "Space", - "noclip_toggle": "N", - "music_toggle": "M", - "music_toggle_gamepad": "start", - "gamepad_move_x_axis": "leftx", - "gamepad_move_y_axis": "lefty", - "gamepad_look_x_axis": "rightx", - "gamepad_look_y_axis": "righty", - "gamepad_dpad_up": "dpup", - "gamepad_dpad_down": "dpdown", - "gamepad_dpad_left": "dpleft", - "gamepad_dpad_right": "dpright", - "gamepad_button_actions": { - "a": "gamepad_a", - "b": "gamepad_b", - "x": "gamepad_x", - "y": "gamepad_y", - "leftshoulder": "gamepad_lb", - "rightshoulder": "gamepad_rb", - "leftstick": "gamepad_ls", - "rightstick": "gamepad_rs", - "back": "gamepad_back", - "start": "gamepad_start" + "window": { + "title": "SDL3 Bgfx Demo", + "size": { + "width": 1024, + "height": 768 }, - "gamepad_axis_actions": { - "lefttrigger": "gamepad_lt", - "righttrigger": "gamepad_rt" - }, - "gamepad_axis_action_threshold": 0.5 - }, - "project_root": "../", - "shaders_directory": "shaders", - "bgfx": { - "renderer": "opengl" - }, - "materialx": { - "enabled": true, - "parameters_enabled": true, - "library_path": "MaterialX/libraries", - "library_folders": [ - "stdlib", - "pbrlib", - "lights", - "bxdf", - "cmlib", - "nprlib", - "targets" - ] - }, - "materialx_materials": [ - { - "shader_key": "floor", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_wood_tiled.mtlx", - "material": "Tiled_Wood" - }, - { - "shader_key": "wall", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brick_procedural.mtlx", - "material": "M_BrickPattern" - }, - { - "shader_key": "ceiling", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx", - "material": "Marble_3D" - }, - { - "shader_key": "solid", - "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brass_tiled.mtlx", - "material": "Tiled_Brass" + "mouse_grab": { + "enabled": true, + "grab_on_click": true, + "release_on_escape": true, + "start_grabbed": false, + "hide_cursor": true, + "relative_mode": true, + "grab_mouse_button": "left", + "release_key": "escape" } - ], - "atmospherics": { - "ambient_strength": 0.006, - "fog_density": 0.006, - "fog_color": [0.03, 0.04, 0.06], - "sky_color": [0.02, 0.03, 0.05], - "gamma": 2.2, - "exposure": 1.15, - "enable_tone_mapping": true, - "enable_shadows": true, - "enable_ssgi": true, - "enable_volumetric_lighting": true, - "pbr_roughness": 0.28, - "pbr_metallic": 0.08 }, - "gui_font": { - "use_freetype": true, - "font_path": "scripts/assets/fonts/Roboto-Regular.ttf", - "font_size": 18.0 + "scripts": { + "entry": "scripts/cube_logic.lua", + "lua_debug": false }, - "gui_opacity": 1.0, - "config_file": "config/seed_runtime.json" + "paths": { + "project_root": "../", + "scripts": "scripts", + "shaders": "shaders" + }, + "input": { + "bindings": { + "move_forward": "W", + "move_back": "S", + "move_left": "A", + "move_right": "D", + "fly_up": "Q", + "fly_down": "Z", + "jump": "Space", + "noclip_toggle": "N", + "music_toggle": "M", + "music_toggle_gamepad": "start", + "gamepad_move_x_axis": "leftx", + "gamepad_move_y_axis": "lefty", + "gamepad_look_x_axis": "rightx", + "gamepad_look_y_axis": "righty", + "gamepad_dpad_up": "dpup", + "gamepad_dpad_down": "dpdown", + "gamepad_dpad_left": "dpleft", + "gamepad_dpad_right": "dpright", + "gamepad_button_actions": { + "a": "gamepad_a", + "b": "gamepad_b", + "x": "gamepad_x", + "y": "gamepad_y", + "leftshoulder": "gamepad_lb", + "rightshoulder": "gamepad_rb", + "leftstick": "gamepad_ls", + "rightstick": "gamepad_rs", + "back": "gamepad_back", + "start": "gamepad_start" + }, + "gamepad_axis_actions": { + "lefttrigger": "gamepad_lt", + "righttrigger": "gamepad_rt" + }, + "gamepad_axis_action_threshold": 0.5 + } + }, + "rendering": { + "bgfx": { + "renderer": "opengl" + }, + "materialx": { + "enabled": true, + "parameters_enabled": true, + "library_path": "MaterialX/libraries", + "library_folders": [ + "stdlib", + "pbrlib", + "lights", + "bxdf", + "cmlib", + "nprlib", + "targets" + ], + "materials": [ + { + "shader_key": "floor", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_wood_tiled.mtlx", + "material": "Tiled_Wood" + }, + { + "shader_key": "wall", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brick_procedural.mtlx", + "material": "M_BrickPattern" + }, + { + "shader_key": "ceiling", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_marble_solid.mtlx", + "material": "Marble_3D" + }, + { + "shader_key": "solid", + "document": "MaterialX/resources/Materials/Examples/StandardSurface/standard_surface_brass_tiled.mtlx", + "material": "Tiled_Brass" + } + ] + }, + "atmospherics": { + "ambient_strength": 0.006, + "fog_density": 0.006, + "fog_color": [0.03, 0.04, 0.06], + "sky_color": [0.02, 0.03, 0.05], + "gamma": 2.2, + "exposure": 1.15, + "enable_tone_mapping": true, + "enable_shadows": true, + "enable_ssgi": true, + "enable_volumetric_lighting": true, + "pbr_roughness": 0.28, + "pbr_metallic": 0.08 + } + }, + "gui": { + "font": { + "use_freetype": true, + "font_path": "scripts/assets/fonts/Roboto-Regular.ttf", + "font_size": 18.0 + }, + "opacity": 1.0 + }, + "config_file": "config/seed_runtime_opengl.json" } diff --git a/scripts/config_resolver.lua b/scripts/config_resolver.lua new file mode 100644 index 0000000..6d02c51 --- /dev/null +++ b/scripts/config_resolver.lua @@ -0,0 +1,85 @@ +local resolver = {} + +local function as_table(value) + if type(value) == "table" then + return value + end + return nil +end + +function resolver.resolve_materialx(config) + local root = as_table(config) + if not root then + return nil + end + local rendering = as_table(root.rendering) + if rendering then + local materialx = as_table(rendering.materialx) + if materialx then + return materialx + end + end + return as_table(root.materialx) +end + +function resolver.resolve_materialx_materials(config) + local materialx = resolver.resolve_materialx(config) + if materialx then + local materials = as_table(materialx.materials) + if materials then + return materials + end + end + local root = as_table(config) + if root then + return as_table(root.materialx_materials) + end + return nil +end + +function resolver.resolve_input_bindings(config) + local root = as_table(config) + if not root then + return nil + end + local input = as_table(root.input) + if input then + local bindings = as_table(input.bindings) + if bindings then + return bindings + end + end + return as_table(root.input_bindings) +end + +function resolver.resolve_gui_font(config) + local root = as_table(config) + if not root then + return nil + end + local gui = as_table(root.gui) + if gui then + local font = as_table(gui.font) + if font then + return font + end + end + return as_table(root.gui_font) +end + +function resolver.resolve_gui_opacity(config) + local root = as_table(config) + if not root then + return nil + end + local gui = as_table(root.gui) + if gui and type(gui.opacity) == "number" then + return gui.opacity + end + if type(root.gui_opacity) == "number" then + return root.gui_opacity + end + return nil +end + +return resolver diff --git a/scripts/cube_logic.lua b/scripts/cube_logic.lua index 1bed888..4c4a05a 100644 --- a/scripts/cube_logic.lua +++ b/scripts/cube_logic.lua @@ -1,5 +1,6 @@ local scene_framework = require("scene_framework") local math3d = require("math3d") +local config_resolver = require("config_resolver") local cube_mesh_info = { path = "models/cube.stl", @@ -709,19 +710,19 @@ local function resolve_material_shader() if type(config) ~= "table" then error("Missing config table for MaterialX shader selection") end - local materialx = config.materialx + local materialx = config_resolver.resolve_materialx(config) if type(materialx) ~= "table" or not materialx.enabled then error("MaterialX config missing or disabled; shader selection cannot proceed") end - local materials = config.materialx_materials + local materials = config_resolver.resolve_materialx_materials(config) if type(materials) == "table" and type(materials[1]) == "table" then local first_key = materials[1].shader_key if type(first_key) == "string" and first_key ~= "" then - log_debug("Using first materialx_materials shader_key=%s", first_key) + log_debug("Using first materialx materials shader_key=%s", first_key) return first_key end end - error("MaterialX enabled but no materialx_materials shader_key found") + error("MaterialX enabled but no materials shader_key found") end -- Delegate to framework diff --git a/scripts/dev_commands.py b/scripts/dev_commands.py index 74ab06f..313ff74 100755 --- a/scripts/dev_commands.py +++ b/scripts/dev_commands.py @@ -1250,30 +1250,45 @@ def gui(args: argparse.Namespace) -> None: "description": f"3D {name} project based on cube demo template", "enabled": True }, - "window_width": 1024, - "window_height": 768, - "lua_script": f"scripts/{project_id}_logic.lua", - "scripts_directory": "scripts", - "mouse_grab": { - "enabled": True, - "grab_on_click": True, - "release_on_escape": True, - "start_grabbed": False, - "hide_cursor": True, - "relative_mode": True, - "grab_mouse_button": "left", - "release_key": "escape" + "schema_version": 2, + "window": { + "title": name, + "size": { + "width": 1024, + "height": 768 + }, + "mouse_grab": { + "enabled": True, + "grab_on_click": True, + "release_on_escape": True, + "start_grabbed": False, + "hide_cursor": True, + "relative_mode": True, + "grab_mouse_button": "left", + "release_key": "escape" + } }, - "input_bindings": { - "move_forward": "W", - "move_back": "S", - "move_left": "A", - "move_right": "D", - "fly_up": "Q", - "fly_down": "Z", - "jump": "Space", - "noclip_toggle": "N", - "music_toggle": "M" + "scripts": { + "entry": f"scripts/{project_id}_logic.lua", + "lua_debug": False + }, + "paths": { + "project_root": "../", + "scripts": "scripts", + "shaders": "shaders" + }, + "input": { + "bindings": { + "move_forward": "W", + "move_back": "S", + "move_left": "A", + "move_right": "D", + "fly_up": "Q", + "fly_down": "Z", + "jump": "Space", + "noclip_toggle": "N", + "music_toggle": "M" + } } }, "lua_script": f"""-- {name} Logic Script @@ -1512,6 +1527,10 @@ return {{ config_data = json.load(f) lua_script_path = config_data.get("lua_script", "") + if not lua_script_path: + scripts_config = config_data.get("scripts", {}) + if isinstance(scripts_config, dict): + lua_script_path = scripts_config.get("entry", "") if not lua_script_path: self.lua_editor.setPlainText("# No Lua script specified in config") self.lua_file_label.setText("No Lua script found") diff --git a/scripts/gui.lua b/scripts/gui.lua index 1326c31..49ddfb2 100644 --- a/scripts/gui.lua +++ b/scripts/gui.lua @@ -1,5 +1,6 @@ -- Lightweight Lua-based 2D GUI framework that emits draw commands -- and handles interaction for buttons, textboxes, and list views. +local config_resolver = require("config_resolver") local Gui = {} -- {r,g,b,a} colors @@ -126,14 +127,15 @@ function Context:new(options) options = options or {} local style = options.style or DEFAULT_STYLE if options.style == nil and type(config) == "table" then - local guiFont = config.gui_font + local guiFont = config_resolver.resolve_gui_font(config) if type(guiFont) == "table" and type(guiFont.font_size) == "number" then style.fontSize = guiFont.font_size end end local opacity = 1.0 - if type(config) == "table" and type(config.gui_opacity) == "number" then - opacity = config.gui_opacity + local resolvedOpacity = config_resolver.resolve_gui_opacity(config) + if type(resolvedOpacity) == "number" then + opacity = resolvedOpacity end local instance = { commands = {}, diff --git a/scripts/gui_demo.lua b/scripts/gui_demo.lua index 059bca3..ddbb613 100644 --- a/scripts/gui_demo.lua +++ b/scripts/gui_demo.lua @@ -1,5 +1,6 @@ local Gui = require('gui') local math3d = require('math3d') +local config_resolver = require('config_resolver') local ctx = Gui.newContext() local input = Gui.newInputState() @@ -24,7 +25,7 @@ local fpsMode = false local fpsToggleWasDown = false local fpsToggleKey = "F1" if type(config) == "table" then - local bindings = config.input_bindings + local bindings = config_resolver.resolve_input_bindings(config) if type(bindings) == "table" then local key = bindings.fps_toggle or bindings.fps_toggle_key if type(key) == "string" or type(key) == "number" then diff --git a/scripts/quake3_arena.lua b/scripts/quake3_arena.lua index f5e8fb7..fff4b74 100644 --- a/scripts/quake3_arena.lua +++ b/scripts/quake3_arena.lua @@ -1,4 +1,5 @@ local math3d = require("math3d") +local config_resolver = require("config_resolver") local function log_debug(fmt, ...) if not lua_debug or not fmt then @@ -59,15 +60,20 @@ local map_offset = resolve_vec3(quake3_config.offset, {0.0, 0.0, 0.0}) local map_shader_key = nil if type(quake3_config.shader_key) == "string" and quake3_config.shader_key ~= "" then map_shader_key = quake3_config.shader_key -elseif type(config) == "table" - and type(config.materialx_materials) == "table" - and type(config.materialx_materials[1]) == "table" - and type(config.materialx_materials[1].shader_key) == "string" - and config.materialx_materials[1].shader_key ~= "" then - map_shader_key = config.materialx_materials[1].shader_key - log_debug("Using MaterialX shader_key for Quake3 map=%s", map_shader_key) else - error("Quake3 config requires a shader_key or materialx_materials[1].shader_key") + local materials = config_resolver.resolve_materialx_materials(config) + if type(materials) == "table" + and type(materials[1]) == "table" + and type(materials[1].shader_key) == "string" + and materials[1].shader_key ~= "" then + map_shader_key = materials[1].shader_key + end +end + +if map_shader_key then + log_debug("Using shader_key for Quake3 map=%s", map_shader_key) +else + error("Quake3 config requires a shader_key or a MaterialX materials shader_key") end local function scale_matrix(x, y, z) @@ -218,7 +224,7 @@ local fallback_bindings = { noclip_toggle = "N", } -local input_bindings = resolve_table(type(config) == "table" and config.input_bindings) +local input_bindings = resolve_table(config_resolver.resolve_input_bindings(config)) local function get_binding(action_name) if type(input_bindings[action_name]) == "string" then return input_bindings[action_name] diff --git a/scripts/scene_framework.lua b/scripts/scene_framework.lua index d63c893..0ed6539 100644 --- a/scripts/scene_framework.lua +++ b/scripts/scene_framework.lua @@ -2,6 +2,7 @@ -- Provides mesh generation, object builders, and utility functions local math3d = require("math3d") +local config_resolver = require("config_resolver") local framework = {} @@ -328,8 +329,9 @@ function framework.MaterialRegistry.new(config) self.materials = {} self.default_key = nil - if config and config.materialx_materials then - for i, mat in ipairs(config.materialx_materials) do + local materials = config_resolver.resolve_materialx_materials(config) + if type(materials) == "table" then + for i, mat in ipairs(materials) do if mat.shader_key then self.materials[mat.shader_key] = mat if i == 1 then diff --git a/scripts/soundboard.lua b/scripts/soundboard.lua index 0bef819..832a790 100644 --- a/scripts/soundboard.lua +++ b/scripts/soundboard.lua @@ -1,10 +1,18 @@ local Gui = require("gui") local math3d = require("math3d") +local config_resolver = require("config_resolver") local ctx = Gui.newContext() local input = Gui.newInputState() local statusMessage = "Select a clip to play" +local function log_debug(fmt, ...) + if not lua_debug or not fmt then + return + end + print(string.format(fmt, ...)) +end + local function findScriptDirectory() local info = debug.getinfo(1, "S") local source = info.source or "" @@ -247,17 +255,20 @@ local function createCube(position) local offset = math3d.translation(position[1], position[2], position[3]) return math3d.multiply(offset, base) end + local materials = config_resolver.resolve_materialx_materials(config) + if type(materials) ~= "table" + or type(materials[1]) ~= "table" + or type(materials[1].shader_key) ~= "string" + or materials[1].shader_key == "" then + error("Soundboard requires rendering.materialx.materials[1].shader_key or materialx_materials[1].shader_key") + end + local shader_key = materials[1].shader_key + log_debug("Soundboard using material shader_key=%s", shader_key) return { vertices = cubeVertices, indices = cubeIndices, compute_model_matrix = computeModel, - if type(config) ~= "table" or type(config.materialx_materials) ~= "table" or - type(config.materialx_materials[1]) ~= "table" or - type(config.materialx_materials[1].shader_key) ~= "string" or - config.materialx_materials[1].shader_key == "" then - error("Soundboard requires materialx_materials[1].shader_key") - end - shader_keys = {config.materialx_materials[1].shader_key}, + shader_keys = {shader_key}, } end diff --git a/scripts/test_scene_framework.lua b/scripts/test_scene_framework.lua index 9105839..81fc56e 100644 --- a/scripts/test_scene_framework.lua +++ b/scripts/test_scene_framework.lua @@ -214,10 +214,14 @@ assert_equal(time_value, 42, "dynamic compute function receives time") print("Testing material registry...") local test_config = { - materialx_materials = { - {shader_key = "floor", document = "floor.mtlx", material = "Floor"}, - {shader_key = "wall", document = "wall.mtlx", material = "Wall"}, - {shader_key = "ceiling", document = "ceiling.mtlx", material = "Ceiling"}, + rendering = { + materialx = { + materials = { + {shader_key = "floor", document = "floor.mtlx", material = "Floor"}, + {shader_key = "wall", document = "wall.mtlx", material = "Wall"}, + {shader_key = "ceiling", document = "ceiling.mtlx", material = "Ceiling"}, + } + } } } diff --git a/src/app/service_based_app.cpp b/src/app/service_based_app.cpp index 12592e9..b87d945 100644 --- a/src/app/service_based_app.cpp +++ b/src/app/service_based_app.cpp @@ -132,14 +132,15 @@ void ServiceBasedApp::Run() { // Run the main application loop with crash recovery if (crashRecoveryService_) { + constexpr int kMainLoopTimeoutMs = 24 * 60 * 60 * 1000; // Safety net; heartbeat monitor handles hangs. bool success = crashRecoveryService_->ExecuteWithTimeout( [this]() { applicationLoopService_->Run(); }, - 30000, // 30 second timeout for the main loop + kMainLoopTimeoutMs, "Main Application Loop" ); if (!success) { - logger_->Warn("ServiceBasedApp::Run: Main loop timed out, attempting recovery"); + logger_->Warn("ServiceBasedApp::Run: Main loop stopped by crash recovery, attempting recovery"); if (crashRecoveryService_->AttemptRecovery()) { logger_->Info("ServiceBasedApp::Run: Recovery successful, restarting main loop"); applicationLoopService_->Run(); // Try again @@ -329,7 +330,8 @@ void ServiceBasedApp::RegisterServices() { registry_.GetService(), registry_.GetService(), registry_.GetService(), - registry_.GetService()); + registry_.GetService(), + registry_.GetService()); logger_->Trace("ServiceBasedApp", "RegisterServices", "", "Exiting"); } diff --git a/src/services/impl/application_loop_service.cpp b/src/services/impl/application_loop_service.cpp index 839999b..16b95c8 100644 --- a/src/services/impl/application_loop_service.cpp +++ b/src/services/impl/application_loop_service.cpp @@ -11,7 +11,8 @@ ApplicationLoopService::ApplicationLoopService(std::shared_ptr logger, std::shared_ptr physicsService, std::shared_ptr sceneService, std::shared_ptr renderCoordinatorService, - std::shared_ptr audioService) + std::shared_ptr audioService, + std::shared_ptr crashRecoveryService) : logger_(std::move(logger)), windowService_(std::move(windowService)), eventBus_(std::move(eventBus)), @@ -19,7 +20,8 @@ ApplicationLoopService::ApplicationLoopService(std::shared_ptr logger, physicsService_(std::move(physicsService)), sceneService_(std::move(sceneService)), renderCoordinatorService_(std::move(renderCoordinatorService)), - audioService_(std::move(audioService)) { + audioService_(std::move(audioService)), + crashRecoveryService_(std::move(crashRecoveryService)) { if (logger_) { logger_->Trace("ApplicationLoopService", "ApplicationLoopService", "windowService=" + std::string(windowService_ ? "set" : "null") + @@ -28,7 +30,8 @@ ApplicationLoopService::ApplicationLoopService(std::shared_ptr logger, ", physicsService=" + std::string(physicsService_ ? "set" : "null") + ", sceneService=" + std::string(sceneService_ ? "set" : "null") + ", renderCoordinatorService=" + std::string(renderCoordinatorService_ ? "set" : "null") + - ", audioService=" + std::string(audioService_ ? "set" : "null"), + ", audioService=" + std::string(audioService_ ? "set" : "null") + + ", crashRecoveryService=" + std::string(crashRecoveryService_ ? "set" : "null"), "Created"); } } @@ -49,6 +52,18 @@ void ApplicationLoopService::Run() { float deltaTime = std::chrono::duration(currentTime - lastTime).count(); float elapsedTime = std::chrono::duration(currentTime - startTime).count(); lastTime = currentTime; + const double elapsedSeconds = static_cast(elapsedTime); + + if (crashRecoveryService_) { + crashRecoveryService_->RecordFrameHeartbeat(static_cast(deltaTime)); + if (elapsedSeconds - lastMemoryCheckSeconds_ >= memoryCheckIntervalSeconds_) { + lastMemoryCheckSeconds_ = elapsedSeconds; + if (!crashRecoveryService_->CheckMemoryHealth()) { + logger_->Warn("ApplicationLoopService::Run: Memory health check failed, stopping loop"); + running_ = false; + } + } + } HandleEvents(); ProcessFrame(deltaTime, elapsedTime); diff --git a/src/services/impl/crash_recovery_service.cpp b/src/services/impl/crash_recovery_service.cpp index c3b1003..092af7d 100644 --- a/src/services/impl/crash_recovery_service.cpp +++ b/src/services/impl/crash_recovery_service.cpp @@ -24,6 +24,12 @@ std::string BuildStackTrace() { } return trace.to_string(); } + +int64_t GetSteadyClockNs() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} } // Static instance for signal handler @@ -33,13 +39,16 @@ CrashRecoveryService::CrashRecoveryService(std::shared_ptr logger) : logger_(logger) , crashDetected_(false) , lastSignal_(0) - , signalHandlersInstalled_(false) + , lastHeartbeatNs_(0) + , heartbeatSeen_(false) + , heartbeatMonitorRunning_(false) , lastSuccessfulFrameTime_(0.0) , consecutiveFrameTimeouts_(0) , luaExecutionFailures_(0) , fileFormatErrors_(0) , memoryWarnings_(0) - , lastHealthCheck_(std::chrono::steady_clock::now()) { + , lastHealthCheck_(std::chrono::steady_clock::now()) + , signalHandlersInstalled_(false) { logger_->Trace("CrashRecoveryService", "CrashRecoveryService", "logger=" + std::string(logger_ ? "set" : "null"), "Created"); @@ -56,14 +65,25 @@ void CrashRecoveryService::Initialize() { SetupSignalHandlers(); crashDetected_ = false; lastSignal_ = 0; + lastHeartbeatNs_ = 0; + heartbeatSeen_ = false; crashReport_.clear(); + if (!heartbeatMonitorRunning_.exchange(true)) { + heartbeatMonitorThread_ = std::thread(&CrashRecoveryService::MonitorHeartbeats, this); + } + logger_->Info("CrashRecoveryService::Initialize: Crash recovery service initialized"); } void CrashRecoveryService::Shutdown() { logger_->Trace("CrashRecoveryService", "Shutdown", "", "Shutting down crash recovery service"); + heartbeatMonitorRunning_ = false; + if (heartbeatMonitorThread_.joinable()) { + heartbeatMonitorThread_.join(); + } + RemoveSignalHandlers(); logger_->Info("CrashRecoveryService::Shutdown: Crash recovery service shutdown"); @@ -87,47 +107,102 @@ bool CrashRecoveryService::ExecuteWithTimeout(std::function func, int ti } }); - if (completionFuture.wait_for(std::chrono::milliseconds(timeoutMs)) == std::future_status::timeout) { - logger_->Warn("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' timed out after " + std::to_string(timeoutMs) + "ms"); - logger_->Trace("CrashRecoveryService", "ExecuteWithTimeout", - "timeoutMs=" + std::to_string(timeoutMs) + ", operationName=" + operationName, - "Timeout detected; marking crash and detaching worker thread"); + const auto start = std::chrono::steady_clock::now(); + const auto timeoutDuration = std::chrono::milliseconds(timeoutMs); + const auto pollInterval = std::chrono::milliseconds(50); - { + while (true) { + if (completionFuture.wait_for(pollInterval) == std::future_status::ready) { + try { + completionFuture.get(); // Re-throw any exceptions + logger_->Trace("CrashRecoveryService", "ExecuteWithTimeout", "", "Operation completed successfully"); + if (workerThread.joinable()) { + workerThread.join(); + } + return true; + } catch (const std::exception& e) { + logger_->Error("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' threw exception: " + e.what()); + if (workerThread.joinable()) { + workerThread.join(); + } + throw; + } catch (...) { + logger_->Error("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' threw unknown exception"); + if (workerThread.joinable()) { + workerThread.join(); + } + throw; + } + } + + if (crashDetected_.load()) { + logger_->Warn("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' aborted after crash detection"); + if (workerThread.joinable()) { + workerThread.detach(); + } + return false; + } + + if (timeoutMs >= 0 && std::chrono::steady_clock::now() - start >= timeoutDuration) { + logger_->Warn("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' timed out after " + std::to_string(timeoutMs) + "ms"); + logger_->Trace("CrashRecoveryService", "ExecuteWithTimeout", + "timeoutMs=" + std::to_string(timeoutMs) + ", operationName=" + operationName, + "Timeout detected; marking crash and detaching worker thread"); + + { + std::lock_guard lock(crashMutex_); + crashDetected_ = true; + crashReport_ += "\nTIMEOUT: Operation '" + operationName + "' exceeded " + + std::to_string(timeoutMs) + "ms\n"; + } + + // No safe way to cancel; detach so we can report the timeout promptly. + if (workerThread.joinable()) { + workerThread.detach(); + } + + return false; + } + } +} + +void CrashRecoveryService::RecordFrameHeartbeat(double frameTimeSeconds) { + lastHeartbeatNs_.store(GetSteadyClockNs(), std::memory_order_relaxed); + heartbeatSeen_.store(true, std::memory_order_release); + lastSuccessfulFrameTime_ = frameTimeSeconds; +} + +void CrashRecoveryService::MonitorHeartbeats() { + logger_->Trace("CrashRecoveryService", "MonitorHeartbeats", "", "Starting heartbeat monitor"); + + const int64_t timeoutNs = static_cast(heartbeatTimeout_.count()) * 1000 * 1000; + while (heartbeatMonitorRunning_.load()) { + std::this_thread::sleep_for(heartbeatPollInterval_); + if (!heartbeatSeen_.load(std::memory_order_acquire)) { + continue; + } + + const int64_t lastHeartbeat = lastHeartbeatNs_.load(std::memory_order_relaxed); + if (lastHeartbeat == 0) { + continue; + } + + const int64_t nowNs = GetSteadyClockNs(); + const int64_t elapsedNs = nowNs - lastHeartbeat; + if (elapsedNs >= timeoutNs) { + const int64_t elapsedMs = elapsedNs / (1000 * 1000); std::lock_guard lock(crashMutex_); - crashDetected_ = true; - crashReport_ += "\nTIMEOUT: Operation '" + operationName + "' exceeded " + - std::to_string(timeoutMs) + "ms\n"; + if (!crashDetected_) { + crashDetected_ = true; + crashReport_ += "\nHEARTBEAT TIMEOUT: No frame heartbeat for " + + std::to_string(elapsedMs) + "ms\n"; + logger_->Error("CrashRecoveryService::MonitorHeartbeats: Frame heartbeat stalled for " + + std::to_string(elapsedMs) + "ms"); + } } - - // No safe way to cancel; detach so we can report the timeout promptly. - if (workerThread.joinable()) { - workerThread.detach(); - } - - return false; } - try { - completionFuture.get(); // Re-throw any exceptions - logger_->Trace("CrashRecoveryService", "ExecuteWithTimeout", "", "Operation completed successfully"); - if (workerThread.joinable()) { - workerThread.join(); - } - return true; - } catch (const std::exception& e) { - logger_->Error("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' threw exception: " + e.what()); - if (workerThread.joinable()) { - workerThread.join(); - } - throw; - } catch (...) { - logger_->Error("CrashRecoveryService::ExecuteWithTimeout: Operation '" + operationName + "' threw unknown exception"); - if (workerThread.joinable()) { - workerThread.join(); - } - throw; - } + logger_->Trace("CrashRecoveryService", "MonitorHeartbeats", "", "Heartbeat monitor exiting"); } bool CrashRecoveryService::IsCrashDetected() const { @@ -150,6 +225,8 @@ bool CrashRecoveryService::AttemptRecovery() { crashDetected_ = false; lastSignal_ = 0; crashReport_.clear(); + lastHeartbeatNs_ = 0; + heartbeatSeen_ = false; logger_->Info("CrashRecoveryService::AttemptRecovery: Recovery successful"); } else { logger_->Error("CrashRecoveryService::AttemptRecovery: Recovery failed"); @@ -435,6 +512,13 @@ std::string CrashRecoveryService::GetSystemHealthStatus() const { ss << "File Format Errors: " << fileFormatErrors_ << "\n"; ss << "Memory Warnings: " << memoryWarnings_ << "\n"; ss << "Last Successful Frame Time: " << lastSuccessfulFrameTime_ << " seconds\n"; + if (heartbeatSeen_.load(std::memory_order_acquire)) { + const int64_t lastHeartbeat = lastHeartbeatNs_.load(std::memory_order_relaxed); + const int64_t ageMs = (GetSteadyClockNs() - lastHeartbeat) / (1000 * 1000); + ss << "Last Frame Heartbeat Age: " << ageMs << " ms\n"; + } else { + ss << "Last Frame Heartbeat Age: n/a\n"; + } auto now = std::chrono::steady_clock::now(); auto timeSinceLastCheck = std::chrono::duration_cast( diff --git a/src/services/impl/crash_recovery_service.hpp b/src/services/impl/crash_recovery_service.hpp index 9832931..05bd36d 100644 --- a/src/services/impl/crash_recovery_service.hpp +++ b/src/services/impl/crash_recovery_service.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -27,6 +28,7 @@ public: void Initialize() override; void Shutdown() override; bool ExecuteWithTimeout(std::function func, int timeoutMs, const std::string& operationName) override; + void RecordFrameHeartbeat(double frameTimeSeconds) override; bool IsCrashDetected() const override; bool AttemptRecovery() override; std::string GetCrashReport() const override; @@ -50,10 +52,17 @@ private: void UpdateHealthMetrics(); size_t GetCurrentMemoryUsage() const; bool IsGpuResponsive() const; + void MonitorHeartbeats(); std::shared_ptr logger_; std::atomic crashDetected_; std::atomic lastSignal_; + std::atomic lastHeartbeatNs_; + std::atomic heartbeatSeen_; + std::atomic heartbeatMonitorRunning_; + std::thread heartbeatMonitorThread_; + std::chrono::milliseconds heartbeatTimeout_{5000}; + std::chrono::milliseconds heartbeatPollInterval_{200}; std::string crashReport_; mutable std::mutex crashMutex_; @@ -74,4 +83,4 @@ private: bool signalHandlersInstalled_; }; -} // namespace sdl3cpp::services::impl \ No newline at end of file +} // namespace sdl3cpp::services::impl diff --git a/src/services/impl/json_config_service.cpp b/src/services/impl/json_config_service.cpp index fe42baa..3c4d16c 100644 --- a/src/services/impl/json_config_service.cpp +++ b/src/services/impl/json_config_service.cpp @@ -110,15 +110,68 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, } } - const char* scriptField = "lua_script"; - if (!document.HasMember(scriptField) || !document[scriptField].IsString()) { - throw std::runtime_error("JSON config requires a string member '" + std::string(scriptField) + "'"); + auto getObjectMember = [&](const rapidjson::Value& parent, + const char* name, + const char* fullName) -> const rapidjson::Value* { + if (!parent.HasMember(name)) { + return nullptr; + } + const auto& value = parent[name]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member '" + std::string(fullName) + "' must be an object"); + } + return &value; + }; + + const auto* scriptsValue = getObjectMember(document, "scripts", "scripts"); + const auto* pathsValue = getObjectMember(document, "paths", "paths"); + const auto* windowValue = getObjectMember(document, "window", "window"); + const auto* windowSizeValue = windowValue + ? getObjectMember(*windowValue, "size", "window.size") + : nullptr; + const auto* inputValue = getObjectMember(document, "input", "input"); + const auto* inputBindingsValue = inputValue + ? getObjectMember(*inputValue, "bindings", "input.bindings") + : nullptr; + const auto* renderingValue = getObjectMember(document, "rendering", "rendering"); + const auto* guiValue = getObjectMember(document, "gui", "gui"); + + std::optional scriptPathValue; + if (scriptsValue && scriptsValue->HasMember("entry")) { + const auto& value = (*scriptsValue)["entry"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'scripts.entry' must be a string"); + } + scriptPathValue = value.GetString(); + } else if (document.HasMember("lua_script")) { + const auto& value = document["lua_script"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'lua_script' must be a string"); + } + scriptPathValue = value.GetString(); + } + if (!scriptPathValue) { + throw std::runtime_error("JSON config requires a string member 'scripts.entry' or 'lua_script'"); } std::optional projectRoot; - const char* projectRootField = "project_root"; - if (document.HasMember(projectRootField) && document[projectRootField].IsString()) { - std::filesystem::path candidate(document[projectRootField].GetString()); + if (pathsValue && pathsValue->HasMember("project_root")) { + const auto& value = (*pathsValue)["project_root"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'paths.project_root' must be a string"); + } + std::filesystem::path candidate(value.GetString()); + if (candidate.is_absolute()) { + projectRoot = std::filesystem::weakly_canonical(candidate); + } else { + projectRoot = std::filesystem::weakly_canonical(configPath.parent_path() / candidate); + } + } else if (document.HasMember("project_root")) { + const auto& value = document["project_root"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'project_root' must be a string"); + } + std::filesystem::path candidate(value.GetString()); if (candidate.is_absolute()) { projectRoot = std::filesystem::weakly_canonical(candidate); } else { @@ -127,8 +180,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, } RuntimeConfig config; - const auto& scriptValue = document[scriptField]; - std::filesystem::path scriptPath(scriptValue.GetString()); + std::filesystem::path scriptPath(*scriptPathValue); if (!scriptPath.is_absolute()) { if (projectRoot) { scriptPath = *projectRoot / scriptPath; @@ -159,10 +211,38 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, throw std::runtime_error(std::string("JSON member '") + name + "' must be a non-negative integer"); }; - config.width = parseDimension("window_width", config.width); - config.height = parseDimension("window_height", config.height); + auto parseDimensionValue = [&](const rapidjson::Value& value, const char* name) -> uint32_t { + if (value.IsUint()) { + return value.GetUint(); + } + if (value.IsInt()) { + int maybeValue = value.GetInt(); + if (maybeValue >= 0) { + return static_cast(maybeValue); + } + } + throw std::runtime_error(std::string("JSON member '") + name + "' must be a non-negative integer"); + }; - if (document.HasMember("lua_debug")) { + if (windowSizeValue) { + if (windowSizeValue->HasMember("width")) { + config.width = parseDimensionValue((*windowSizeValue)["width"], "window.size.width"); + } + if (windowSizeValue->HasMember("height")) { + config.height = parseDimensionValue((*windowSizeValue)["height"], "window.size.height"); + } + } else { + config.width = parseDimension("window_width", config.width); + config.height = parseDimension("window_height", config.height); + } + + if (scriptsValue && scriptsValue->HasMember("lua_debug")) { + const auto& value = (*scriptsValue)["lua_debug"]; + if (!value.IsBool()) { + throw std::runtime_error("JSON member 'scripts.lua_debug' must be a boolean"); + } + config.luaDebug = value.GetBool(); + } else if (document.HasMember("lua_debug")) { const auto& value = document["lua_debug"]; if (!value.IsBool()) { throw std::runtime_error("JSON member 'lua_debug' must be a boolean"); @@ -170,7 +250,13 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, config.luaDebug = value.GetBool(); } - if (document.HasMember("window_title")) { + if (windowValue && windowValue->HasMember("title")) { + const auto& value = (*windowValue)["title"]; + if (!value.IsString()) { + throw std::runtime_error("JSON member 'window.title' must be a string"); + } + config.windowTitle = value.GetString(); + } else if (document.HasMember("window_title")) { const auto& value = document["window_title"]; if (!value.IsString()) { throw std::runtime_error("JSON member 'window_title' must be a string"); @@ -178,28 +264,40 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, config.windowTitle = value.GetString(); } - if (document.HasMember("mouse_grab")) { - const auto& mouseGrabValue = document["mouse_grab"]; - if (!mouseGrabValue.IsObject()) { + const rapidjson::Value* mouseGrabValue = nullptr; + std::string mouseGrabPath = "mouse_grab"; + if (windowValue && windowValue->HasMember("mouse_grab")) { + const auto& value = (*windowValue)["mouse_grab"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'window.mouse_grab' must be an object"); + } + mouseGrabValue = &value; + mouseGrabPath = "window.mouse_grab"; + } else if (document.HasMember("mouse_grab")) { + const auto& value = document["mouse_grab"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'mouse_grab' must be an object"); } + mouseGrabValue = &value; + } + if (mouseGrabValue) { auto readBool = [&](const char* name, bool& target) { - if (!mouseGrabValue.HasMember(name)) { + if (!mouseGrabValue->HasMember(name)) { return; } - const auto& value = mouseGrabValue[name]; + const auto& value = (*mouseGrabValue)[name]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'mouse_grab." + std::string(name) + "' must be a boolean"); + throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a boolean"); } target = value.GetBool(); }; auto readString = [&](const char* name, std::string& target) { - if (!mouseGrabValue.HasMember(name)) { + if (!mouseGrabValue->HasMember(name)) { return; } - const auto& value = mouseGrabValue[name]; + const auto& value = (*mouseGrabValue)[name]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'mouse_grab." + std::string(name) + "' must be a string"); + throw std::runtime_error("JSON member '" + mouseGrabPath + "." + std::string(name) + "' must be a string"); } target = value.GetString(); }; @@ -213,11 +311,19 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, readString("release_key", config.mouseGrab.releaseKey); } - if (document.HasMember("input_bindings")) { - const auto& bindingsValue = document["input_bindings"]; - if (!bindingsValue.IsObject()) { + const rapidjson::Value* bindingsValue = nullptr; + std::string bindingsPath = "input_bindings"; + if (inputBindingsValue) { + bindingsValue = inputBindingsValue; + bindingsPath = "input.bindings"; + } else if (document.HasMember("input_bindings")) { + const auto& value = document["input_bindings"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'input_bindings' must be an object"); } + bindingsValue = &value; + } + if (bindingsValue) { struct BindingSpec { const char* name; std::string InputBindings::* member; @@ -244,12 +350,12 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, }}; auto readBinding = [&](const BindingSpec& spec) { - if (!bindingsValue.HasMember(spec.name)) { + if (!bindingsValue->HasMember(spec.name)) { return; } - const auto& value = bindingsValue[spec.name]; + const auto& value = (*bindingsValue)[spec.name]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'input_bindings." + std::string(spec.name) + "' must be a string"); + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(spec.name) + "' must be a string"); } config.inputBindings.*(spec.member) = value.GetString(); }; @@ -260,16 +366,16 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, auto readMapping = [&](const char* name, std::unordered_map& target) { - if (!bindingsValue.HasMember(name)) { + if (!bindingsValue->HasMember(name)) { return; } - const auto& mappingValue = bindingsValue[name]; + const auto& mappingValue = (*bindingsValue)[name]; if (!mappingValue.IsObject()) { - throw std::runtime_error("JSON member 'input_bindings." + std::string(name) + "' must be an object"); + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + "' must be an object"); } for (auto it = mappingValue.MemberBegin(); it != mappingValue.MemberEnd(); ++it) { if (!it->name.IsString() || !it->value.IsString()) { - throw std::runtime_error("JSON member 'input_bindings." + std::string(name) + + throw std::runtime_error("JSON member '" + bindingsPath + "." + std::string(name) + "' must contain string pairs"); } std::string key = it->name.GetString(); @@ -285,54 +391,69 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, readMapping("gamepad_button_actions", config.inputBindings.gamepadButtonActions); readMapping("gamepad_axis_actions", config.inputBindings.gamepadAxisActions); - if (bindingsValue.HasMember("gamepad_axis_action_threshold")) { - const auto& value = bindingsValue["gamepad_axis_action_threshold"]; + if (bindingsValue->HasMember("gamepad_axis_action_threshold")) { + const auto& value = (*bindingsValue)["gamepad_axis_action_threshold"]; if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'input_bindings.gamepad_axis_action_threshold' must be a number"); + throw std::runtime_error("JSON member '" + bindingsPath + ".gamepad_axis_action_threshold' must be a number"); } config.inputBindings.gamepadAxisActionThreshold = static_cast(value.GetDouble()); } } - if (document.HasMember("atmospherics")) { - const auto& atmosphericsValue = document["atmospherics"]; - if (!atmosphericsValue.IsObject()) { + const rapidjson::Value* atmosphericsValue = nullptr; + std::string atmosphericsPath = "atmospherics"; + if (renderingValue) { + atmosphericsValue = getObjectMember(*renderingValue, "atmospherics", "rendering.atmospherics"); + if (atmosphericsValue) { + atmosphericsPath = "rendering.atmospherics"; + } + } + if (!atmosphericsValue && document.HasMember("atmospherics")) { + const auto& value = document["atmospherics"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'atmospherics' must be an object"); } + atmosphericsValue = &value; + } + if (atmosphericsValue) { auto readFloat = [&](const char* name, float& target) { - if (!atmosphericsValue.HasMember(name)) { + if (!atmosphericsValue->HasMember(name)) { return; } - const auto& value = atmosphericsValue[name]; + const auto& value = (*atmosphericsValue)[name]; if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'atmospherics." + std::string(name) + "' must be a number"); + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "' must be a number"); } target = static_cast(value.GetDouble()); }; auto readBool = [&](const char* name, bool& target) { - if (!atmosphericsValue.HasMember(name)) { + if (!atmosphericsValue->HasMember(name)) { return; } - const auto& value = atmosphericsValue[name]; + const auto& value = (*atmosphericsValue)[name]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'atmospherics." + std::string(name) + "' must be a boolean"); + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "' must be a boolean"); } target = value.GetBool(); }; auto readFloatArray3 = [&](const char* name, std::array& target) { - if (!atmosphericsValue.HasMember(name)) { + if (!atmosphericsValue->HasMember(name)) { return; } - const auto& value = atmosphericsValue[name]; + const auto& value = (*atmosphericsValue)[name]; if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member 'atmospherics." + std::string(name) + "' must be an array of 3 numbers"); + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "' must be an array of 3 numbers"); } for (rapidjson::SizeType i = 0; i < 3; ++i) { if (!value[i].IsNumber()) { - throw std::runtime_error("JSON member 'atmospherics." + std::string(name) + "[" + std::to_string(i) + "]' must be a number"); + throw std::runtime_error("JSON member '" + atmosphericsPath + "." + std::string(name) + + "[" + std::to_string(i) + "]' must be a number"); } target[i] = static_cast(value[i].GetDouble()); } @@ -352,112 +473,151 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, readFloat("pbr_metallic", config.atmospherics.pbrMetallic); } - if (document.HasMember("bgfx")) { - const auto& bgfxValue = document["bgfx"]; - if (!bgfxValue.IsObject()) { + const rapidjson::Value* bgfxValue = nullptr; + std::string bgfxPath = "bgfx"; + if (renderingValue) { + bgfxValue = getObjectMember(*renderingValue, "bgfx", "rendering.bgfx"); + if (bgfxValue) { + bgfxPath = "rendering.bgfx"; + } + } + if (!bgfxValue && document.HasMember("bgfx")) { + const auto& value = document["bgfx"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'bgfx' must be an object"); } - if (bgfxValue.HasMember("renderer")) { - const auto& value = bgfxValue["renderer"]; + bgfxValue = &value; + } + if (bgfxValue) { + if (bgfxValue->HasMember("renderer")) { + const auto& value = (*bgfxValue)["renderer"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'bgfx.renderer' must be a string"); + throw std::runtime_error("JSON member '" + bgfxPath + ".renderer' must be a string"); } config.bgfx.renderer = value.GetString(); } } - bool materialShaderKeyProvided = false; - if (document.HasMember("materialx")) { - const auto& materialValue = document["materialx"]; - if (!materialValue.IsObject()) { + const rapidjson::Value* materialValue = nullptr; + std::string materialPath = "materialx"; + if (renderingValue) { + materialValue = getObjectMember(*renderingValue, "materialx", "rendering.materialx"); + if (materialValue) { + materialPath = "rendering.materialx"; + } + } + if (!materialValue && document.HasMember("materialx")) { + const auto& value = document["materialx"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'materialx' must be an object"); } - if (materialValue.HasMember("enabled")) { - const auto& value = materialValue["enabled"]; + materialValue = &value; + } + + bool materialShaderKeyProvided = false; + if (materialValue) { + if (materialValue->HasMember("enabled")) { + const auto& value = (*materialValue)["enabled"]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'materialx.enabled' must be a boolean"); + throw std::runtime_error("JSON member '" + materialPath + ".enabled' must be a boolean"); } config.materialX.enabled = value.GetBool(); } - if (materialValue.HasMember("document")) { - const auto& value = materialValue["document"]; + if (materialValue->HasMember("document")) { + const auto& value = (*materialValue)["document"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx.document' must be a string"); + throw std::runtime_error("JSON member '" + materialPath + ".document' must be a string"); } config.materialX.documentPath = value.GetString(); } - if (materialValue.HasMember("shader_key")) { - const auto& value = materialValue["shader_key"]; + if (materialValue->HasMember("shader_key")) { + const auto& value = (*materialValue)["shader_key"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx.shader_key' must be a string"); + throw std::runtime_error("JSON member '" + materialPath + ".shader_key' must be a string"); } config.materialX.shaderKey = value.GetString(); materialShaderKeyProvided = true; } - if (materialValue.HasMember("material")) { - const auto& value = materialValue["material"]; + if (materialValue->HasMember("material")) { + const auto& value = (*materialValue)["material"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx.material' must be a string"); + throw std::runtime_error("JSON member '" + materialPath + ".material' must be a string"); } config.materialX.materialName = value.GetString(); } - if (materialValue.HasMember("library_path")) { - const auto& value = materialValue["library_path"]; + if (materialValue->HasMember("library_path")) { + const auto& value = (*materialValue)["library_path"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx.library_path' must be a string"); + throw std::runtime_error("JSON member '" + materialPath + ".library_path' must be a string"); } config.materialX.libraryPath = value.GetString(); } - if (materialValue.HasMember("library_folders")) { - const auto& value = materialValue["library_folders"]; + if (materialValue->HasMember("library_folders")) { + const auto& value = (*materialValue)["library_folders"]; if (!value.IsArray()) { - throw std::runtime_error("JSON member 'materialx.library_folders' must be an array"); + throw std::runtime_error("JSON member '" + materialPath + ".library_folders' must be an array"); } config.materialX.libraryFolders.clear(); for (rapidjson::SizeType i = 0; i < value.Size(); ++i) { if (!value[i].IsString()) { - throw std::runtime_error("JSON member 'materialx.library_folders[" + std::to_string(i) + "]' must be a string"); + throw std::runtime_error("JSON member '" + materialPath + ".library_folders[" + + std::to_string(i) + "]' must be a string"); } config.materialX.libraryFolders.emplace_back(value[i].GetString()); } } - if (materialValue.HasMember("use_constant_color")) { - const auto& value = materialValue["use_constant_color"]; + if (materialValue->HasMember("use_constant_color")) { + const auto& value = (*materialValue)["use_constant_color"]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'materialx.use_constant_color' must be a boolean"); + throw std::runtime_error("JSON member '" + materialPath + ".use_constant_color' must be a boolean"); } config.materialX.useConstantColor = value.GetBool(); } - if (materialValue.HasMember("constant_color")) { - const auto& value = materialValue["constant_color"]; + if (materialValue->HasMember("constant_color")) { + const auto& value = (*materialValue)["constant_color"]; if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member 'materialx.constant_color' must be an array of 3 numbers"); + throw std::runtime_error("JSON member '" + materialPath + ".constant_color' must be an array of 3 numbers"); } for (rapidjson::SizeType i = 0; i < 3; ++i) { if (!value[i].IsNumber()) { - throw std::runtime_error("JSON member 'materialx.constant_color[" + std::to_string(i) + "]' must be a number"); + throw std::runtime_error("JSON member '" + materialPath + ".constant_color[" + + std::to_string(i) + "]' must be a number"); } config.materialX.constantColor[i] = static_cast(value[i].GetDouble()); } } } - if (document.HasMember("materialx_materials")) { - const auto& materialsValue = document["materialx_materials"]; - if (!materialsValue.IsArray()) { + const rapidjson::Value* materialsValue = nullptr; + std::string materialsPath = "materialx_materials"; + if (materialValue && materialValue->HasMember("materials")) { + const auto& value = (*materialValue)["materials"]; + if (!value.IsArray()) { + throw std::runtime_error("JSON member '" + materialPath + ".materials' must be an array"); + } + materialsValue = &value; + materialsPath = materialPath + ".materials"; + } + if (!materialsValue && document.HasMember("materialx_materials")) { + const auto& value = document["materialx_materials"]; + if (!value.IsArray()) { throw std::runtime_error("JSON member 'materialx_materials' must be an array"); } + materialsValue = &value; + } + if (materialsValue) { config.materialXMaterials.clear(); - for (rapidjson::SizeType i = 0; i < materialsValue.Size(); ++i) { - const auto& entry = materialsValue[i]; + for (rapidjson::SizeType i = 0; i < materialsValue->Size(); ++i) { + const auto& entry = (*materialsValue)[i]; if (!entry.IsObject()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + "]' must be an object"); + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + + "]' must be an object"); } MaterialXMaterialConfig materialConfig; if (entry.HasMember("enabled")) { const auto& value = entry["enabled"]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].enabled' must be a boolean"); } materialConfig.enabled = value.GetBool(); @@ -465,7 +625,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, if (entry.HasMember("document")) { const auto& value = entry["document"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].document' must be a string"); } materialConfig.documentPath = value.GetString(); @@ -473,7 +633,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, if (entry.HasMember("shader_key")) { const auto& value = entry["shader_key"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].shader_key' must be a string"); } materialConfig.shaderKey = value.GetString(); @@ -481,7 +641,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, if (entry.HasMember("material")) { const auto& value = entry["material"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].material' must be a string"); } materialConfig.materialName = value.GetString(); @@ -489,7 +649,7 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, if (entry.HasMember("use_constant_color")) { const auto& value = entry["use_constant_color"]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].use_constant_color' must be a boolean"); } materialConfig.useConstantColor = value.GetBool(); @@ -497,12 +657,12 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, if (entry.HasMember("constant_color")) { const auto& value = entry["constant_color"]; if (!value.IsArray() || value.Size() != 3) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].constant_color' must be an array of 3 numbers"); } for (rapidjson::SizeType channel = 0; channel < 3; ++channel) { if (!value[channel].IsNumber()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].constant_color[" + std::to_string(channel) + "]' must be a number"); } @@ -511,11 +671,11 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, } if (materialConfig.shaderKey.empty()) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].shader_key' must be provided"); } if (materialConfig.documentPath.empty() && !materialConfig.useConstantColor) { - throw std::runtime_error("JSON member 'materialx_materials[" + std::to_string(i) + + throw std::runtime_error("JSON member '" + materialsPath + "[" + std::to_string(i) + "].document' is required when use_constant_color is false"); } @@ -532,35 +692,53 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr logger, } } - if (document.HasMember("gui_font")) { - const auto& fontValue = document["gui_font"]; - if (!fontValue.IsObject()) { + const rapidjson::Value* guiFontValue = nullptr; + std::string guiFontPath = "gui_font"; + if (guiValue && guiValue->HasMember("font")) { + const auto& value = (*guiValue)["font"]; + if (!value.IsObject()) { + throw std::runtime_error("JSON member 'gui.font' must be an object"); + } + guiFontValue = &value; + guiFontPath = "gui.font"; + } else if (document.HasMember("gui_font")) { + const auto& value = document["gui_font"]; + if (!value.IsObject()) { throw std::runtime_error("JSON member 'gui_font' must be an object"); } - if (fontValue.HasMember("use_freetype")) { - const auto& value = fontValue["use_freetype"]; + guiFontValue = &value; + } + if (guiFontValue) { + if (guiFontValue->HasMember("use_freetype")) { + const auto& value = (*guiFontValue)["use_freetype"]; if (!value.IsBool()) { - throw std::runtime_error("JSON member 'gui_font.use_freetype' must be a boolean"); + throw std::runtime_error("JSON member '" + guiFontPath + ".use_freetype' must be a boolean"); } config.guiFont.useFreeType = value.GetBool(); } - if (fontValue.HasMember("font_path")) { - const auto& value = fontValue["font_path"]; + if (guiFontValue->HasMember("font_path")) { + const auto& value = (*guiFontValue)["font_path"]; if (!value.IsString()) { - throw std::runtime_error("JSON member 'gui_font.font_path' must be a string"); + throw std::runtime_error("JSON member '" + guiFontPath + ".font_path' must be a string"); } config.guiFont.fontPath = value.GetString(); } - if (fontValue.HasMember("font_size")) { - const auto& value = fontValue["font_size"]; + if (guiFontValue->HasMember("font_size")) { + const auto& value = (*guiFontValue)["font_size"]; if (!value.IsNumber()) { - throw std::runtime_error("JSON member 'gui_font.font_size' must be a number"); + throw std::runtime_error("JSON member '" + guiFontPath + ".font_size' must be a number"); } config.guiFont.fontSize = static_cast(value.GetDouble()); } } - if (document.HasMember("gui_opacity")) { + if (guiValue && guiValue->HasMember("opacity")) { + const auto& value = (*guiValue)["opacity"]; + if (!value.IsNumber()) { + throw std::runtime_error("JSON member 'gui.opacity' must be a number"); + } + config.guiOpacity = static_cast(value.GetDouble()); + } else if (document.HasMember("gui_opacity")) { const auto& value = document["gui_opacity"]; if (!value.IsNumber()) { throw std::runtime_error("JSON member 'gui_opacity' must be a number"); @@ -577,22 +755,27 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, document.SetObject(); auto& allocator = document.GetAllocator(); - auto addStringMember = [&](const char* name, const std::string& value) { + auto addStringMember = [&](rapidjson::Value& target, const char* name, const std::string& value) { rapidjson::Value nameValue(name, allocator); rapidjson::Value stringValue(value.c_str(), allocator); - document.AddMember(nameValue, stringValue, allocator); + target.AddMember(nameValue, stringValue, allocator); }; - document.AddMember("window_width", config.width, allocator); - document.AddMember("window_height", config.height, allocator); - addStringMember("lua_script", config.scriptPath.string()); - addStringMember("window_title", config.windowTitle); - document.AddMember("lua_debug", config.luaDebug, allocator); + document.AddMember("schema_version", 2, allocator); + + rapidjson::Value scriptsObject(rapidjson::kObjectType); + addStringMember(scriptsObject, "entry", config.scriptPath.string()); + scriptsObject.AddMember("lua_debug", config.luaDebug, allocator); + document.AddMember("scripts", scriptsObject, allocator); + + rapidjson::Value windowObject(rapidjson::kObjectType); + addStringMember(windowObject, "title", config.windowTitle); + rapidjson::Value sizeObject(rapidjson::kObjectType); + sizeObject.AddMember("width", config.width, allocator); + sizeObject.AddMember("height", config.height, allocator); + windowObject.AddMember("size", sizeObject, allocator); std::filesystem::path scriptsDir = config.scriptPath.parent_path(); - if (!scriptsDir.empty()) { - addStringMember("scripts_directory", scriptsDir.string()); - } rapidjson::Value mouseGrabObject(rapidjson::kObjectType); mouseGrabObject.AddMember("enabled", config.mouseGrab.enabled, allocator); @@ -607,13 +790,13 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, mouseGrabObject.AddMember("release_key", rapidjson::Value(config.mouseGrab.releaseKey.c_str(), allocator), allocator); - document.AddMember("mouse_grab", mouseGrabObject, allocator); + windowObject.AddMember("mouse_grab", mouseGrabObject, allocator); + document.AddMember("window", windowObject, allocator); rapidjson::Value bgfxObject(rapidjson::kObjectType); bgfxObject.AddMember("renderer", rapidjson::Value(config.bgfx.renderer.c_str(), allocator), allocator); - document.AddMember("bgfx", bgfxObject, allocator); rapidjson::Value materialObject(rapidjson::kObjectType); materialObject.AddMember("enabled", config.materialX.enabled, allocator); @@ -640,7 +823,6 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, constantColor.PushBack(config.materialX.constantColor[1], allocator); constantColor.PushBack(config.materialX.constantColor[2], allocator); materialObject.AddMember("constant_color", constantColor, allocator); - document.AddMember("materialx", materialObject, allocator); if (!config.materialXMaterials.empty()) { rapidjson::Value materialsArray(rapidjson::kArrayType); @@ -664,16 +846,36 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, entry.AddMember("constant_color", materialColor, allocator); materialsArray.PushBack(entry, allocator); } - document.AddMember("materialx_materials", materialsArray, allocator); + materialObject.AddMember("materials", materialsArray, allocator); } - rapidjson::Value fontObject(rapidjson::kObjectType); - fontObject.AddMember("use_freetype", config.guiFont.useFreeType, allocator); - fontObject.AddMember("font_path", - rapidjson::Value(config.guiFont.fontPath.string().c_str(), allocator), - allocator); - fontObject.AddMember("font_size", config.guiFont.fontSize, allocator); - document.AddMember("gui_font", fontObject, allocator); + rapidjson::Value renderingObject(rapidjson::kObjectType); + renderingObject.AddMember("bgfx", bgfxObject, allocator); + renderingObject.AddMember("materialx", materialObject, allocator); + + rapidjson::Value atmosphericsObject(rapidjson::kObjectType); + atmosphericsObject.AddMember("ambient_strength", config.atmospherics.ambientStrength, allocator); + atmosphericsObject.AddMember("fog_density", config.atmospherics.fogDensity, allocator); + rapidjson::Value fogColor(rapidjson::kArrayType); + fogColor.PushBack(config.atmospherics.fogColor[0], allocator); + fogColor.PushBack(config.atmospherics.fogColor[1], allocator); + fogColor.PushBack(config.atmospherics.fogColor[2], allocator); + atmosphericsObject.AddMember("fog_color", fogColor, allocator); + rapidjson::Value skyColor(rapidjson::kArrayType); + skyColor.PushBack(config.atmospherics.skyColor[0], allocator); + skyColor.PushBack(config.atmospherics.skyColor[1], allocator); + skyColor.PushBack(config.atmospherics.skyColor[2], allocator); + atmosphericsObject.AddMember("sky_color", skyColor, allocator); + atmosphericsObject.AddMember("gamma", config.atmospherics.gamma, allocator); + atmosphericsObject.AddMember("exposure", config.atmospherics.exposure, allocator); + atmosphericsObject.AddMember("enable_tone_mapping", config.atmospherics.enableToneMapping, allocator); + atmosphericsObject.AddMember("enable_shadows", config.atmospherics.enableShadows, allocator); + atmosphericsObject.AddMember("enable_ssgi", config.atmospherics.enableSSGI, allocator); + atmosphericsObject.AddMember("enable_volumetric_lighting", config.atmospherics.enableVolumetricLighting, allocator); + atmosphericsObject.AddMember("pbr_roughness", config.atmospherics.pbrRoughness, allocator); + atmosphericsObject.AddMember("pbr_metallic", config.atmospherics.pbrMetallic, allocator); + renderingObject.AddMember("atmospherics", atmosphericsObject, allocator); + document.AddMember("rendering", renderingObject, allocator); rapidjson::Value bindingsObject(rapidjson::kObjectType); auto addBindingMember = [&](const char* name, const std::string& value) { @@ -725,20 +927,36 @@ std::string JsonConfigService::BuildConfigJson(const RuntimeConfig& config, addMappingObject("gamepad_axis_actions", config.inputBindings.gamepadAxisActions, bindingsObject); bindingsObject.AddMember("gamepad_axis_action_threshold", config.inputBindings.gamepadAxisActionThreshold, allocator); - document.AddMember("input_bindings", bindingsObject, allocator); + rapidjson::Value inputObject(rapidjson::kObjectType); + inputObject.AddMember("bindings", bindingsObject, allocator); + document.AddMember("input", inputObject, allocator); std::filesystem::path projectRoot = scriptsDir.parent_path(); - if (!projectRoot.empty()) { - addStringMember("project_root", projectRoot.string()); - addStringMember("shaders_directory", (projectRoot / "shaders").string()); - } else { - addStringMember("shaders_directory", "shaders"); + rapidjson::Value pathsObject(rapidjson::kObjectType); + if (!scriptsDir.empty()) { + addStringMember(pathsObject, "scripts", scriptsDir.string()); } + if (!projectRoot.empty()) { + addStringMember(pathsObject, "project_root", projectRoot.string()); + addStringMember(pathsObject, "shaders", (projectRoot / "shaders").string()); + } else { + addStringMember(pathsObject, "shaders", "shaders"); + } + document.AddMember("paths", pathsObject, allocator); - document.AddMember("gui_opacity", config.guiOpacity, allocator); + rapidjson::Value guiObject(rapidjson::kObjectType); + rapidjson::Value fontObject(rapidjson::kObjectType); + fontObject.AddMember("use_freetype", config.guiFont.useFreeType, allocator); + fontObject.AddMember("font_path", + rapidjson::Value(config.guiFont.fontPath.string().c_str(), allocator), + allocator); + fontObject.AddMember("font_size", config.guiFont.fontSize, allocator); + guiObject.AddMember("font", fontObject, allocator); + guiObject.AddMember("opacity", config.guiOpacity, allocator); + document.AddMember("gui", guiObject, allocator); if (!configPath.empty()) { - addStringMember("config_file", configPath.string()); + addStringMember(document, "config_file", configPath.string()); } rapidjson::StringBuffer buffer; diff --git a/src/services/impl/json_config_writer_service.cpp b/src/services/impl/json_config_writer_service.cpp index d40c1e9..d7020bb 100644 --- a/src/services/impl/json_config_writer_service.cpp +++ b/src/services/impl/json_config_writer_service.cpp @@ -35,18 +35,27 @@ void JsonConfigWriterService::WriteConfig(const RuntimeConfig& config, const std document.SetObject(); auto& allocator = document.GetAllocator(); - auto addStringMember = [&](const char* name, const std::string& value) { + auto addStringMember = [&](rapidjson::Value& target, const char* name, const std::string& value) { rapidjson::Value nameValue(name, allocator); rapidjson::Value stringValue(value.c_str(), allocator); - document.AddMember(nameValue, stringValue, allocator); + target.AddMember(nameValue, stringValue, allocator); }; - document.AddMember("window_width", config.width, allocator); - document.AddMember("window_height", config.height, allocator); - addStringMember("lua_script", config.scriptPath.string()); + document.AddMember("schema_version", 2, allocator); + + rapidjson::Value scriptsObject(rapidjson::kObjectType); + addStringMember(scriptsObject, "entry", config.scriptPath.string()); + scriptsObject.AddMember("lua_debug", config.luaDebug, allocator); + document.AddMember("scripts", scriptsObject, allocator); + + rapidjson::Value windowObject(rapidjson::kObjectType); + addStringMember(windowObject, "title", config.windowTitle); + rapidjson::Value sizeObject(rapidjson::kObjectType); + sizeObject.AddMember("width", config.width, allocator); + sizeObject.AddMember("height", config.height, allocator); + windowObject.AddMember("size", sizeObject, allocator); std::filesystem::path scriptsDir = config.scriptPath.parent_path(); - addStringMember("scripts_directory", scriptsDir.string()); rapidjson::Value mouseGrabObject(rapidjson::kObjectType); mouseGrabObject.AddMember("enabled", config.mouseGrab.enabled, allocator); @@ -61,7 +70,8 @@ void JsonConfigWriterService::WriteConfig(const RuntimeConfig& config, const std mouseGrabObject.AddMember("release_key", rapidjson::Value(config.mouseGrab.releaseKey.c_str(), allocator), allocator); - document.AddMember("mouse_grab", mouseGrabObject, allocator); + windowObject.AddMember("mouse_grab", mouseGrabObject, allocator); + document.AddMember("window", windowObject, allocator); rapidjson::Value bindingsObject(rapidjson::kObjectType); auto addBindingMember = [&](const char* name, const std::string& value) { @@ -113,22 +123,120 @@ void JsonConfigWriterService::WriteConfig(const RuntimeConfig& config, const std addMappingObject("gamepad_axis_actions", config.inputBindings.gamepadAxisActions, bindingsObject); bindingsObject.AddMember("gamepad_axis_action_threshold", config.inputBindings.gamepadAxisActionThreshold, allocator); - document.AddMember("input_bindings", bindingsObject, allocator); + rapidjson::Value inputObject(rapidjson::kObjectType); + inputObject.AddMember("bindings", bindingsObject, allocator); + document.AddMember("input", inputObject, allocator); std::filesystem::path projectRoot = scriptsDir.parent_path(); - if (!projectRoot.empty()) { - addStringMember("project_root", projectRoot.string()); - addStringMember("shaders_directory", (projectRoot / "shaders").string()); - } else { - addStringMember("shaders_directory", "shaders"); + rapidjson::Value pathsObject(rapidjson::kObjectType); + if (!scriptsDir.empty()) { + addStringMember(pathsObject, "scripts", scriptsDir.string()); } + if (!projectRoot.empty()) { + addStringMember(pathsObject, "project_root", projectRoot.string()); + addStringMember(pathsObject, "shaders", (projectRoot / "shaders").string()); + } else { + addStringMember(pathsObject, "shaders", "shaders"); + } + document.AddMember("paths", pathsObject, allocator); rapidjson::Value bgfxObject(rapidjson::kObjectType); bgfxObject.AddMember("renderer", rapidjson::Value(config.bgfx.renderer.c_str(), allocator), allocator); - document.AddMember("bgfx", bgfxObject, allocator); - addStringMember("config_file", configPath.string()); + rapidjson::Value materialObject(rapidjson::kObjectType); + materialObject.AddMember("enabled", config.materialX.enabled, allocator); + materialObject.AddMember("document", + rapidjson::Value(config.materialX.documentPath.string().c_str(), allocator), + allocator); + materialObject.AddMember("shader_key", + rapidjson::Value(config.materialX.shaderKey.c_str(), allocator), + allocator); + materialObject.AddMember("material", + rapidjson::Value(config.materialX.materialName.c_str(), allocator), + allocator); + materialObject.AddMember("library_path", + rapidjson::Value(config.materialX.libraryPath.string().c_str(), allocator), + allocator); + rapidjson::Value libraryFolders(rapidjson::kArrayType); + for (const auto& folder : config.materialX.libraryFolders) { + libraryFolders.PushBack(rapidjson::Value(folder.c_str(), allocator), allocator); + } + materialObject.AddMember("library_folders", libraryFolders, allocator); + materialObject.AddMember("use_constant_color", config.materialX.useConstantColor, allocator); + rapidjson::Value constantColor(rapidjson::kArrayType); + constantColor.PushBack(config.materialX.constantColor[0], allocator); + constantColor.PushBack(config.materialX.constantColor[1], allocator); + constantColor.PushBack(config.materialX.constantColor[2], allocator); + materialObject.AddMember("constant_color", constantColor, allocator); + + if (!config.materialXMaterials.empty()) { + rapidjson::Value materialsArray(rapidjson::kArrayType); + for (const auto& material : config.materialXMaterials) { + rapidjson::Value entry(rapidjson::kObjectType); + entry.AddMember("enabled", material.enabled, allocator); + entry.AddMember("document", + rapidjson::Value(material.documentPath.string().c_str(), allocator), + allocator); + entry.AddMember("shader_key", + rapidjson::Value(material.shaderKey.c_str(), allocator), + allocator); + entry.AddMember("material", + rapidjson::Value(material.materialName.c_str(), allocator), + allocator); + entry.AddMember("use_constant_color", material.useConstantColor, allocator); + rapidjson::Value materialColor(rapidjson::kArrayType); + materialColor.PushBack(material.constantColor[0], allocator); + materialColor.PushBack(material.constantColor[1], allocator); + materialColor.PushBack(material.constantColor[2], allocator); + entry.AddMember("constant_color", materialColor, allocator); + materialsArray.PushBack(entry, allocator); + } + materialObject.AddMember("materials", materialsArray, allocator); + } + + rapidjson::Value atmosphericsObject(rapidjson::kObjectType); + atmosphericsObject.AddMember("ambient_strength", config.atmospherics.ambientStrength, allocator); + atmosphericsObject.AddMember("fog_density", config.atmospherics.fogDensity, allocator); + rapidjson::Value fogColor(rapidjson::kArrayType); + fogColor.PushBack(config.atmospherics.fogColor[0], allocator); + fogColor.PushBack(config.atmospherics.fogColor[1], allocator); + fogColor.PushBack(config.atmospherics.fogColor[2], allocator); + atmosphericsObject.AddMember("fog_color", fogColor, allocator); + rapidjson::Value skyColor(rapidjson::kArrayType); + skyColor.PushBack(config.atmospherics.skyColor[0], allocator); + skyColor.PushBack(config.atmospherics.skyColor[1], allocator); + skyColor.PushBack(config.atmospherics.skyColor[2], allocator); + atmosphericsObject.AddMember("sky_color", skyColor, allocator); + atmosphericsObject.AddMember("gamma", config.atmospherics.gamma, allocator); + atmosphericsObject.AddMember("exposure", config.atmospherics.exposure, allocator); + atmosphericsObject.AddMember("enable_tone_mapping", config.atmospherics.enableToneMapping, allocator); + atmosphericsObject.AddMember("enable_shadows", config.atmospherics.enableShadows, allocator); + atmosphericsObject.AddMember("enable_ssgi", config.atmospherics.enableSSGI, allocator); + atmosphericsObject.AddMember("enable_volumetric_lighting", config.atmospherics.enableVolumetricLighting, allocator); + atmosphericsObject.AddMember("pbr_roughness", config.atmospherics.pbrRoughness, allocator); + atmosphericsObject.AddMember("pbr_metallic", config.atmospherics.pbrMetallic, allocator); + + rapidjson::Value renderingObject(rapidjson::kObjectType); + renderingObject.AddMember("bgfx", bgfxObject, allocator); + renderingObject.AddMember("materialx", materialObject, allocator); + renderingObject.AddMember("atmospherics", atmosphericsObject, allocator); + document.AddMember("rendering", renderingObject, allocator); + + rapidjson::Value guiObject(rapidjson::kObjectType); + rapidjson::Value fontObject(rapidjson::kObjectType); + fontObject.AddMember("use_freetype", config.guiFont.useFreeType, allocator); + fontObject.AddMember("font_path", + rapidjson::Value(config.guiFont.fontPath.string().c_str(), allocator), + allocator); + fontObject.AddMember("font_size", config.guiFont.fontSize, allocator); + guiObject.AddMember("font", fontObject, allocator); + guiObject.AddMember("opacity", config.guiOpacity, allocator); + document.AddMember("gui", guiObject, allocator); + + if (!configPath.empty()) { + addStringMember(document, "config_file", configPath.string()); + } rapidjson::StringBuffer buffer; rapidjson::Writer writer(buffer); diff --git a/src/services/interfaces/i_crash_recovery_service.hpp b/src/services/interfaces/i_crash_recovery_service.hpp index fc70c29..b0fda41 100644 --- a/src/services/interfaces/i_crash_recovery_service.hpp +++ b/src/services/interfaces/i_crash_recovery_service.hpp @@ -35,6 +35,13 @@ public: */ virtual bool ExecuteWithTimeout(std::function func, int timeoutMs, const std::string& operationName) = 0; + /** + * @brief Record a heartbeat for the main loop or long-running operation. + * + * @param frameTimeSeconds Delta time for the last frame in seconds + */ + virtual void RecordFrameHeartbeat(double frameTimeSeconds) = 0; + /** * @brief Check if a crash has been detected. * @@ -98,4 +105,4 @@ public: virtual std::string GetSystemHealthStatus() const = 0; }; -} // namespace sdl3cpp::services \ No newline at end of file +} // namespace sdl3cpp::services