feat: Enhance JSON configuration writer and add heartbeat recording to crash recovery service

- Updated JsonConfigWriterService to structure the JSON output with new sections for scripts, window settings, input bindings, paths, rendering, and GUI configurations.
- Introduced a new method in ICrashRecoveryService to record frame heartbeats, allowing for better tracking of long-running operations.
- Refactored existing code to improve readability and maintainability, including the addition of helper functions for adding string members to JSON objects.
This commit is contained in:
2026-01-08 16:57:24 +00:00
parent 4fdbcdc4bc
commit df19ae9264
18 changed files with 1079 additions and 471 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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 = {},

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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"},
}
}
}
}

View File

@@ -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<services::IPhysicsService>(),
registry_.GetService<services::ISceneService>(),
registry_.GetService<services::IRenderCoordinatorService>(),
registry_.GetService<services::IAudioService>());
registry_.GetService<services::IAudioService>(),
registry_.GetService<services::ICrashRecoveryService>());
logger_->Trace("ServiceBasedApp", "RegisterServices", "", "Exiting");
}

View File

@@ -11,7 +11,8 @@ ApplicationLoopService::ApplicationLoopService(std::shared_ptr<ILogger> logger,
std::shared_ptr<IPhysicsService> physicsService,
std::shared_ptr<ISceneService> sceneService,
std::shared_ptr<IRenderCoordinatorService> renderCoordinatorService,
std::shared_ptr<IAudioService> audioService)
std::shared_ptr<IAudioService> audioService,
std::shared_ptr<ICrashRecoveryService> crashRecoveryService)
: logger_(std::move(logger)),
windowService_(std::move(windowService)),
eventBus_(std::move(eventBus)),
@@ -19,7 +20,8 @@ ApplicationLoopService::ApplicationLoopService(std::shared_ptr<ILogger> 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<ILogger> 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<float>(currentTime - lastTime).count();
float elapsedTime = std::chrono::duration<float>(currentTime - startTime).count();
lastTime = currentTime;
const double elapsedSeconds = static_cast<double>(elapsedTime);
if (crashRecoveryService_) {
crashRecoveryService_->RecordFrameHeartbeat(static_cast<double>(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);

View File

@@ -24,6 +24,12 @@ std::string BuildStackTrace() {
}
return trace.to_string();
}
int64_t GetSteadyClockNs() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
}
}
// Static instance for signal handler
@@ -33,13 +39,16 @@ CrashRecoveryService::CrashRecoveryService(std::shared_ptr<ILogger> 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<void()> 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<std::mutex> 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<int64_t>(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<std::mutex> 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<std::chrono::seconds>(

View File

@@ -7,6 +7,7 @@
#include <mutex>
#include <condition_variable>
#include <csignal>
#include <chrono>
#include <string>
#include <functional>
@@ -27,6 +28,7 @@ public:
void Initialize() override;
void Shutdown() override;
bool ExecuteWithTimeout(std::function<void()> 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<ILogger> logger_;
std::atomic<bool> crashDetected_;
std::atomic<int> lastSignal_;
std::atomic<int64_t> lastHeartbeatNs_;
std::atomic<bool> heartbeatSeen_;
std::atomic<bool> 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
} // namespace sdl3cpp::services::impl

View File

@@ -110,15 +110,68 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> 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<std::string> 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<std::filesystem::path> 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<ILogger> 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<ILogger> 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<uint32_t>(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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> logger,
auto readMapping = [&](const char* name,
std::unordered_map<std::string, std::string>& 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<ILogger> 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<float>(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<float>(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<float, 3>& 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<float>(value[i].GetDouble());
}
@@ -352,112 +473,151 @@ RuntimeConfig JsonConfigService::LoadFromJson(std::shared_ptr<ILogger> 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<float>(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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> 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<ILogger> 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<float>(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<float>(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;

View File

@@ -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<rapidjson::StringBuffer> writer(buffer);

View File

@@ -35,6 +35,13 @@ public:
*/
virtual bool ExecuteWithTimeout(std::function<void()> 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
} // namespace sdl3cpp::services