Split Q3 workflow into composable sub-workflows

Breaks the monolithic Quake3 per-frame workflows into reusable include-based sub-workflows (input, physics, combat, render, overlay, postfx) and adds a screenshot-check component for the screenshot package. Introduces new workflow JSONs (q3_input, q3_physics, q3_combat, q3_render, q3_overlay, q3_postfx, q3_screenshot_check) and updates q3_frame and screenshot q3_frame to include them. Implements SDL software overlay steps (hud, crosshair, hitmarker, mapselect, menu_frame) and software overlay begin/end rendering steps with corresponding headers and source files; removes the old q3_overlay_draw implementation. Updates CMakeLists and workflow parser/registrar to register and build the new sources. This refactor improves composability and reusability of workflow sections.
This commit is contained in:
2026-05-04 19:45:17 +01:00
parent 2ecc36e632
commit 75d6051eca
31 changed files with 1449 additions and 1552 deletions
+8 -1
View File
@@ -189,6 +189,7 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/workflow_definition_parser.cpp
src/services/impl/workflow/workflow_definition_parser_nodes.cpp
src/services/impl/workflow/workflow_definition_parser_variables.cpp
src/services/impl/workflow/workflow_definition_parser_includes.cpp
src/services/impl/workflow/workflow_connection_resolver.cpp
src/services/impl/workflow/workflow_execute_step.cpp
src/stb_image.cpp
@@ -285,7 +286,13 @@ if(BUILD_SDL3_APP)
src/services/impl/workflow/geometry/workflow_geometry_create_plane_step.cpp
src/services/impl/workflow/geometry/workflow_geometry_cube_generate_step.cpp
src/services/impl/workflow/quake3/workflow_q3_menu_update_step.cpp
src/services/impl/workflow/quake3/workflow_q3_overlay_draw_step.cpp
src/services/impl/workflow/rendering/workflow_overlay_sw_begin_step.cpp
src/services/impl/workflow/rendering/workflow_overlay_sw_end_step.cpp
src/services/impl/workflow/quake3/workflow_q3_hud_step.cpp
src/services/impl/workflow/quake3/workflow_q3_crosshair_step.cpp
src/services/impl/workflow/quake3/workflow_q3_hitmarker_step.cpp
src/services/impl/workflow/quake3/workflow_q3_menu_frame_step.cpp
src/services/impl/workflow/quake3/workflow_q3_mapselect_step.cpp
src/services/impl/workflow/quake3/workflow_q3_pickups_draw_step.cpp
src/services/impl/workflow/quake3/workflow_q3_weapon_update_step.cpp
src/services/impl/workflow/quake3/workflow_q3_md3_load_step.cpp
@@ -0,0 +1,20 @@
{
"name": "Q3 Combat",
"description": "Weapon fire + bot AI update.",
"steps": [
{
"id": "q3_weapon",
"plugin": "q3.weapon.update"
},
{
"id": "q3_bots_update",
"plugin": "q3.bots.update",
"parameters": {
"chase_range": 20.0,
"shoot_range": 6.0,
"move_speed": 3.5,
"shoot_interval": 30
}
}
]
}
@@ -1,273 +1,36 @@
{
"name": "Q3 Frame Tick",
"description": "Per-frame: poll input, FPS move, render BSP map with player models, bots, and weapon.",
"nodes": [
"description": "Per-frame composition: each section is a reusable sub-workflow included like a React component.",
"steps": [
{
"id": "input_poll",
"type": "input.poll",
"typeVersion": 1,
"position": [0, 0]
"id": "input_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_input.json" }
},
{
"id": "q3_menu",
"type": "q3.menu.update",
"typeVersion": 1,
"position": [100, 0]
"id": "physics_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_physics.json" }
},
{
"id": "should_stop",
"type": "bool.or",
"typeVersion": 1,
"position": [140, 0],
"inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" },
"outputs": { "value": "q3.stop_game" }
"id": "combat_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_combat.json" }
},
{
"id": "stop_game_loop",
"type": "value.set_if",
"typeVersion": 1,
"position": [160, 0],
"inputs": { "condition": "q3.stop_game" },
"parameters": { "value": false },
"outputs": { "value": "game_running" }
"id": "render_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_render.json" }
},
{
"id": "set_quit_requested",
"type": "value.set_if",
"typeVersion": 1,
"position": [170, 0],
"inputs": { "condition": "q3.menu_quit_pressed" },
"parameters": { "value": true },
"outputs": { "value": "q3.quit_requested" }
"id": "overlay_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_overlay.json" }
},
{
"id": "compute_movement_active",
"type": "bool.not",
"typeVersion": 1,
"position": [190, 0],
"inputs": { "value": "q3.menu_open" },
"outputs": { "value": "movement_active" }
},
{
"id": "update_mouse_grab",
"type": "input.mouse.grab",
"typeVersion": 1,
"position": [195, 0],
"inputs": { "enabled": "movement_active" }
},
{
"id": "physics_move",
"type": "physics.fps.move",
"typeVersion": 1,
"position": [200, 0],
"parameters": {
"move_speed": 6.5,
"sprint_multiplier": 1.5,
"crouch_multiplier": 0.45,
"jump_velocity": 5.5,
"air_control": 0.25,
"gravity_scale": 0.5,
"ground_accel": 30.0,
"ground_friction": 24.0,
"step_height": 0.6,
"crouch_height": 0.5,
"stand_height": 1.4
}
},
{
"id": "physics_step",
"type": "physics.step",
"typeVersion": 1,
"position": [400, 0]
},
{
"id": "sync_transforms",
"type": "physics.sync_transforms",
"typeVersion": 1,
"position": [500, 0]
},
{
"id": "bsp_entities_update",
"type": "bsp.entities.update",
"typeVersion": 1,
"position": [550, 0]
},
{
"id": "camera_update",
"type": "camera.fps.update",
"typeVersion": 1,
"position": [600, 0],
"parameters": {
"sensitivity": 0.003,
"eye_height": 1.4,
"fov": 90.0,
"near": 0.1,
"far": 500.0
}
},
{
"id": "q3_weapon",
"type": "q3.weapon.update",
"typeVersion": 1,
"position": [690, 0]
},
{
"id": "q3_bots_update",
"type": "q3.bots.update",
"typeVersion": 1,
"position": [695, 0],
"parameters": {
"chase_range": 20.0,
"shoot_range": 6.0,
"move_speed": 3.5,
"shoot_interval": 30
}
},
{
"id": "render_prepare",
"type": "render.prepare",
"typeVersion": 1,
"position": [750, 0]
},
{
"id": "portal_view",
"type": "bsp.portal_view",
"typeVersion": 1,
"position": [775, 0]
},
{
"id": "frame_begin",
"type": "frame.gpu.begin",
"typeVersion": 1,
"position": [800, 0],
"parameters": {
"clear_r": 0.3,
"clear_g": 0.5,
"clear_b": 0.8
}
},
{
"id": "draw_map",
"type": "draw.map",
"typeVersion": 1,
"position": [1000, 0],
"parameters": {
"default_texture": "walls_texture",
"pipeline_key": "gpu_pipeline_bsp",
"roughness": 0.7,
"metallic": 0.0
}
},
{
"id": "draw_pickups",
"type": "q3.pickups.draw",
"typeVersion": 1,
"position": [1100, 0]
},
{
"id": "draw_bots",
"type": "q3.bots.draw",
"typeVersion": 1,
"position": [1150, 0],
"parameters": {
"lower_prefix": "lower",
"upper_prefix": "upper",
"head_prefix": "head",
"weapon_prefix": "weapon_mg"
}
},
{
"id": "draw_weapon_viewmodel",
"type": "q3.md3.draw",
"typeVersion": 1,
"position": [1160, 0],
"parameters": {
"prefix": "weapon_mg",
"viewmodel": true,
"vm_right": 0.25,
"vm_down": -0.22,
"vm_fwd": 0.45,
"fps": 15.0
}
},
{
"id": "end_scene",
"type": "frame.gpu.end_scene",
"typeVersion": 1,
"position": [1200, 0]
},
{
"id": "overlay_fps",
"type": "overlay.fps",
"typeVersion": 1,
"position": [1225, 0]
},
{
"id": "q3_overlay",
"type": "q3.overlay.draw",
"typeVersion": 1,
"position": [1235, 0]
},
{
"id": "postfx_taa",
"type": "postfx.taa",
"typeVersion": 1,
"position": [1250, 0],
"parameters": { "blend_factor": 0.05 }
},
{
"id": "postfx_ssao",
"type": "postfx.ssao",
"typeVersion": 1,
"position": [1300, 0]
},
{
"id": "bloom_extract",
"type": "postfx.bloom_extract",
"typeVersion": 1,
"position": [1400, 0]
},
{
"id": "bloom_blur",
"type": "postfx.bloom_blur",
"typeVersion": 1,
"position": [1500, 0]
},
{
"id": "postfx_composite",
"type": "postfx.composite",
"typeVersion": 1,
"position": [1600, 0]
"id": "postfx_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_postfx.json" }
}
],
"connections": {
"input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } },
"q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } },
"should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } },
"stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested", "type": "main", "index": 0 }] } },
"set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } },
"compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } },
"update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } },
"physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } },
"physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } },
"sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } },
"bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } },
"camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } },
"q3_weapon": { "main": { "0": [{ "node": "q3_bots_update", "type": "main", "index": 0 }] } },
"q3_bots_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } },
"render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } },
"portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } },
"frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } },
"draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } },
"draw_pickups": { "main": { "0": [{ "node": "draw_bots", "type": "main", "index": 0 }] } },
"draw_bots": { "main": { "0": [{ "node": "draw_weapon_viewmodel", "type": "main", "index": 0 }] } },
"draw_weapon_viewmodel": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } },
"end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } },
"overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } },
"q3_overlay": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } },
"postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } },
"postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } },
"bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } },
"bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } }
}
]
}
@@ -0,0 +1,45 @@
{
"name": "Q3 Input & Menu",
"description": "Poll input, update menu state, compute movement gate.",
"steps": [
{
"id": "input_poll",
"plugin": "input.poll"
},
{
"id": "q3_menu",
"plugin": "q3.menu.update"
},
{
"id": "should_stop",
"plugin": "bool.or",
"inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" },
"outputs": { "value": "q3.stop_game" }
},
{
"id": "stop_game_loop",
"plugin": "value.set_if",
"inputs": { "condition": "q3.stop_game" },
"parameters": { "value": false },
"outputs": { "value": "game_running" }
},
{
"id": "set_quit_requested",
"plugin": "value.set_if",
"inputs": { "condition": "q3.menu_quit_pressed" },
"parameters": { "value": true },
"outputs": { "value": "q3.quit_requested" }
},
{
"id": "compute_movement_active",
"plugin": "bool.not",
"inputs": { "value": "q3.menu_open" },
"outputs": { "value": "movement_active" }
},
{
"id": "update_mouse_grab",
"plugin": "input.mouse.grab",
"inputs": { "enabled": "movement_active" }
}
]
}
@@ -0,0 +1,44 @@
{
"name": "Q3 Overlay",
"description": "SDL software overlay: FPS counter, HUD, crosshair, hitmarker, menu, map-select. Composable like a React component.",
"steps": [
{
"id": "overlay_fps",
"plugin": "overlay.fps"
},
{
"id": "overlay_begin",
"plugin": "overlay.sw.begin",
"parameters": {
"vert_shader_path_msl": "packages/quake3/shaders/msl/overlay.vert.metal",
"frag_shader_path_msl": "packages/quake3/shaders/msl/overlay.frag.metal",
"vert_shader_path_spirv": "packages/quake3/shaders/spirv/overlay.vert.spv",
"frag_shader_path_spirv": "packages/quake3/shaders/spirv/overlay.frag.spv"
}
},
{
"id": "q3_hud",
"plugin": "q3.hud"
},
{
"id": "q3_crosshair",
"plugin": "q3.crosshair"
},
{
"id": "q3_hitmarker",
"plugin": "q3.hitmarker"
},
{
"id": "q3_menu_frame",
"plugin": "q3.menu.frame"
},
{
"id": "q3_mapselect",
"plugin": "q3.mapselect"
},
{
"id": "overlay_end",
"plugin": "overlay.sw.end"
}
]
}
@@ -0,0 +1,46 @@
{
"name": "Q3 Physics",
"description": "FPS movement, physics step, transform sync, BSP entity update, camera.",
"steps": [
{
"id": "physics_move",
"plugin": "physics.fps.move",
"parameters": {
"move_speed": 6.5,
"sprint_multiplier": 1.5,
"crouch_multiplier": 0.45,
"jump_velocity": 5.5,
"air_control": 0.25,
"gravity_scale": 0.5,
"ground_accel": 30.0,
"ground_friction": 24.0,
"step_height": 0.6,
"crouch_height": 0.5,
"stand_height": 1.4
}
},
{
"id": "physics_step",
"plugin": "physics.step"
},
{
"id": "sync_transforms",
"plugin": "physics.sync_transforms"
},
{
"id": "bsp_entities_update",
"plugin": "bsp.entities.update"
},
{
"id": "camera_update",
"plugin": "camera.fps.update",
"parameters": {
"sensitivity": 0.003,
"eye_height": 1.4,
"fov": 90.0,
"near": 0.1,
"far": 500.0
}
}
]
}
@@ -0,0 +1,27 @@
{
"name": "Q3 Post FX",
"description": "TAA, SSAO, bloom extract+blur, composite.",
"steps": [
{
"id": "postfx_taa",
"plugin": "postfx.taa",
"parameters": { "blend_factor": 0.05 }
},
{
"id": "postfx_ssao",
"plugin": "postfx.ssao"
},
{
"id": "bloom_extract",
"plugin": "postfx.bloom_extract"
},
{
"id": "bloom_blur",
"plugin": "postfx.bloom_blur"
},
{
"id": "postfx_composite",
"plugin": "postfx.composite"
}
]
}
@@ -0,0 +1,63 @@
{
"name": "Q3 3D Render",
"description": "Prepare render state, cull with portal PVS, GPU frame, draw world + bots + viewmodel.",
"steps": [
{
"id": "render_prepare",
"plugin": "render.prepare"
},
{
"id": "portal_view",
"plugin": "bsp.portal_view"
},
{
"id": "frame_begin",
"plugin": "frame.gpu.begin",
"parameters": {
"clear_r": 0.3,
"clear_g": 0.5,
"clear_b": 0.8
}
},
{
"id": "draw_map",
"plugin": "draw.map",
"parameters": {
"default_texture": "walls_texture",
"pipeline_key": "gpu_pipeline_bsp",
"roughness": 0.7,
"metallic": 0.0
}
},
{
"id": "draw_pickups",
"plugin": "q3.pickups.draw"
},
{
"id": "draw_bots",
"plugin": "q3.bots.draw",
"parameters": {
"lower_prefix": "lower",
"upper_prefix": "upper",
"head_prefix": "head",
"weapon_prefix": "weapon_mg"
}
},
{
"id": "draw_weapon_viewmodel",
"plugin": "q3.md3.draw",
"parameters": {
"prefix": "weapon_mg",
"viewmodel": true,
"vm_right": 0.25,
"vm_down": -0.22,
"vm_fwd": 0.45,
"fps": 15.0
}
},
{
"id": "end_scene",
"plugin": "frame.gpu.end_scene"
}
]
}
@@ -1,311 +1,41 @@
{
"name": "Q3 Frame Tick",
"description": "Per-frame: poll input, FPS move, render BSP map with bots and weapon. Screenshots after 240 frames then exits.",
"nodes": [
"name": "Q3 Frame Tick (Screenshot)",
"description": "Per-frame composition: shared Q3 sub-workflows + screenshot-check component. Exits after frame 240.",
"steps": [
{
"id": "input_poll",
"type": "input.poll",
"typeVersion": 1,
"position": [0, 0]
"id": "input_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_input.json" }
},
{
"id": "q3_menu",
"type": "q3.menu.update",
"typeVersion": 1,
"position": [100, 0]
"id": "physics_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_physics.json" }
},
{
"id": "should_stop",
"type": "bool.or",
"typeVersion": 1,
"position": [140, 0],
"inputs": { "left": "q3.menu_quit_pressed", "right": "q3.menu_map_selected" },
"outputs": { "value": "q3.stop_game" }
"id": "combat_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_combat.json" }
},
{
"id": "stop_game_loop",
"type": "value.set_if",
"typeVersion": 1,
"position": [160, 0],
"inputs": { "condition": "q3.stop_game" },
"parameters": { "value": false },
"outputs": { "value": "game_running" }
"id": "render_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_render.json" }
},
{
"id": "set_quit_requested",
"type": "value.set_if",
"typeVersion": 1,
"position": [170, 0],
"inputs": { "condition": "q3.menu_quit_pressed" },
"parameters": { "value": true },
"outputs": { "value": "q3.quit_requested" }
"id": "screenshot_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3_screenshot/workflows/q3_screenshot_check.json" }
},
{
"id": "compute_movement_active",
"type": "bool.not",
"typeVersion": 1,
"position": [190, 0],
"inputs": { "value": "q3.menu_open" },
"outputs": { "value": "movement_active" }
"id": "overlay_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_overlay.json" }
},
{
"id": "update_mouse_grab",
"type": "input.mouse.grab",
"typeVersion": 1,
"position": [195, 0],
"inputs": { "enabled": "movement_active" }
},
{
"id": "physics_move",
"type": "physics.fps.move",
"typeVersion": 1,
"position": [200, 0],
"parameters": {
"move_speed": 6.5,
"sprint_multiplier": 1.5,
"crouch_multiplier": 0.45,
"jump_velocity": 5.5,
"air_control": 0.25,
"gravity_scale": 0.5,
"ground_accel": 30.0,
"ground_friction": 24.0,
"step_height": 0.6,
"crouch_height": 0.5,
"stand_height": 1.4
}
},
{
"id": "physics_step",
"type": "physics.step",
"typeVersion": 1,
"position": [400, 0]
},
{
"id": "sync_transforms",
"type": "physics.sync_transforms",
"typeVersion": 1,
"position": [500, 0]
},
{
"id": "bsp_entities_update",
"type": "bsp.entities.update",
"typeVersion": 1,
"position": [550, 0]
},
{
"id": "camera_update",
"type": "camera.fps.update",
"typeVersion": 1,
"position": [600, 0],
"parameters": {
"sensitivity": 0.003,
"eye_height": 1.4,
"fov": 90.0,
"near": 0.1,
"far": 500.0
}
},
{
"id": "q3_weapon",
"type": "q3.weapon.update",
"typeVersion": 1,
"position": [690, 0]
},
{
"id": "q3_bots_update",
"type": "q3.bots.update",
"typeVersion": 1,
"position": [695, 0],
"parameters": {
"chase_range": 20.0,
"shoot_range": 6.0,
"move_speed": 3.5,
"shoot_interval": 30
}
},
{
"id": "render_prepare",
"type": "render.prepare",
"typeVersion": 1,
"position": [750, 0]
},
{
"id": "portal_view",
"type": "bsp.portal_view",
"typeVersion": 1,
"position": [775, 0]
},
{
"id": "frame_begin",
"type": "frame.gpu.begin",
"typeVersion": 1,
"position": [800, 0],
"parameters": {
"clear_r": 0.3,
"clear_g": 0.5,
"clear_b": 0.8
}
},
{
"id": "draw_map",
"type": "draw.map",
"typeVersion": 1,
"position": [1000, 0],
"parameters": {
"default_texture": "walls_texture",
"pipeline_key": "gpu_pipeline_bsp",
"roughness": 0.7,
"metallic": 0.0
}
},
{
"id": "draw_pickups",
"type": "q3.pickups.draw",
"typeVersion": 1,
"position": [1100, 0]
},
{
"id": "draw_bots",
"type": "q3.bots.draw",
"typeVersion": 1,
"position": [1150, 0],
"parameters": {
"lower_prefix": "lower",
"upper_prefix": "upper",
"head_prefix": "head",
"weapon_prefix": "weapon_mg"
}
},
{
"id": "draw_weapon_viewmodel",
"type": "q3.md3.draw",
"typeVersion": 1,
"position": [1160, 0],
"parameters": {
"prefix": "weapon_mg",
"viewmodel": true,
"vm_right": 0.25,
"vm_down": -0.22,
"vm_fwd": 0.45,
"fps": 15.0
}
},
{
"id": "end_scene",
"type": "frame.gpu.end_scene",
"typeVersion": 1,
"position": [1200, 0]
},
{
"id": "overlay_fps",
"type": "overlay.fps",
"typeVersion": 1,
"position": [1225, 0]
},
{
"id": "q3_overlay",
"type": "q3.overlay.draw",
"typeVersion": 1,
"position": [1235, 0]
},
{
"id": "postfx_taa",
"type": "postfx.taa",
"typeVersion": 1,
"position": [1250, 0],
"parameters": { "blend_factor": 0.05 }
},
{
"id": "postfx_ssao",
"type": "postfx.ssao",
"typeVersion": 1,
"position": [1300, 0]
},
{
"id": "bloom_extract",
"type": "postfx.bloom_extract",
"typeVersion": 1,
"position": [1400, 0]
},
{
"id": "bloom_blur",
"type": "postfx.bloom_blur",
"typeVersion": 1,
"position": [1500, 0]
},
{
"id": "postfx_composite",
"type": "postfx.composite",
"typeVersion": 1,
"position": [1600, 0]
},
{
"id": "set_threshold",
"type": "value.literal",
"typeVersion": 1,
"position": [1650, 0],
"parameters": { "value": 240.0 },
"outputs": { "value": "screenshot_threshold" }
},
{
"id": "check_frame_240",
"type": "compare.gte",
"typeVersion": 1,
"position": [1700, 0],
"inputs": { "left": "loop.iteration", "right": "screenshot_threshold" },
"outputs": { "value": "screenshot_due" }
},
{
"id": "set_screenshot_path",
"type": "value.set_if",
"typeVersion": 1,
"position": [1750, 0],
"inputs": { "condition": "screenshot_due" },
"parameters": { "value": "/tmp/q3_screenshot.bmp" },
"outputs": { "value": "screenshot_output_path" }
},
{
"id": "stop_after_screenshot",
"type": "value.set_if",
"typeVersion": 1,
"position": [1800, 0],
"inputs": { "condition": "screenshot_due" },
"parameters": { "value": false },
"outputs": { "value": "game_running" }
"id": "postfx_group",
"plugin": "workflow.include",
"parameters": { "path": "packages/quake3/workflows/q3_postfx.json" }
}
],
"connections": {
"input_poll": { "main": { "0": [{ "node": "q3_menu", "type": "main", "index": 0 }] } },
"q3_menu": { "main": { "0": [{ "node": "should_stop", "type": "main", "index": 0 }] } },
"should_stop": { "main": { "0": [{ "node": "stop_game_loop", "type": "main", "index": 0 }] } },
"stop_game_loop": { "main": { "0": [{ "node": "set_quit_requested", "type": "main", "index": 0 }] } },
"set_quit_requested": { "main": { "0": [{ "node": "compute_movement_active", "type": "main", "index": 0 }] } },
"compute_movement_active": { "main": { "0": [{ "node": "update_mouse_grab", "type": "main", "index": 0 }] } },
"update_mouse_grab": { "main": { "0": [{ "node": "physics_move", "type": "main", "index": 0 }] } },
"physics_move": { "main": { "0": [{ "node": "physics_step", "type": "main", "index": 0 }] } },
"physics_step": { "main": { "0": [{ "node": "sync_transforms", "type": "main", "index": 0 }] } },
"sync_transforms": { "main": { "0": [{ "node": "bsp_entities_update", "type": "main", "index": 0 }] } },
"bsp_entities_update": { "main": { "0": [{ "node": "camera_update", "type": "main", "index": 0 }] } },
"camera_update": { "main": { "0": [{ "node": "q3_weapon", "type": "main", "index": 0 }] } },
"q3_weapon": { "main": { "0": [{ "node": "q3_bots_update", "type": "main", "index": 0 }] } },
"q3_bots_update": { "main": { "0": [{ "node": "render_prepare", "type": "main", "index": 0 }] } },
"render_prepare": { "main": { "0": [{ "node": "portal_view", "type": "main", "index": 0 }] } },
"portal_view": { "main": { "0": [{ "node": "frame_begin", "type": "main", "index": 0 }] } },
"frame_begin": { "main": { "0": [{ "node": "draw_map", "type": "main", "index": 0 }] } },
"draw_map": { "main": { "0": [{ "node": "draw_pickups", "type": "main", "index": 0 }] } },
"draw_pickups": { "main": { "0": [{ "node": "draw_bots", "type": "main", "index": 0 }] } },
"draw_bots": { "main": { "0": [{ "node": "draw_weapon_viewmodel", "type": "main", "index": 0 }] } },
"draw_weapon_viewmodel": { "main": { "0": [{ "node": "end_scene", "type": "main", "index": 0 }] } },
"end_scene": { "main": { "0": [{ "node": "overlay_fps", "type": "main", "index": 0 }] } },
"overlay_fps": { "main": { "0": [{ "node": "q3_overlay", "type": "main", "index": 0 }] } },
"q3_overlay": { "main": { "0": [{ "node": "set_threshold", "type": "main", "index": 0 }] } },
"set_threshold": { "main": { "0": [{ "node": "check_frame_240", "type": "main", "index": 0 }] } },
"check_frame_240": { "main": { "0": [{ "node": "set_screenshot_path", "type": "main", "index": 0 }] } },
"set_screenshot_path": { "main": { "0": [{ "node": "stop_after_screenshot", "type": "main", "index": 0 }] } },
"stop_after_screenshot": { "main": { "0": [{ "node": "postfx_taa", "type": "main", "index": 0 }] } },
"postfx_taa": { "main": { "0": [{ "node": "postfx_ssao", "type": "main", "index": 0 }] } },
"postfx_ssao": { "main": { "0": [{ "node": "bloom_extract", "type": "main", "index": 0 }] } },
"bloom_extract": { "main": { "0": [{ "node": "bloom_blur", "type": "main", "index": 0 }] } },
"bloom_blur": { "main": { "0": [{ "node": "postfx_composite", "type": "main", "index": 0 }] } }
}
]
}
@@ -0,0 +1,39 @@
{
"name": "Q3 Screenshot Check",
"description": "After frame 240: write screenshot path, stop the frame loop, and signal quit so the outer loop exits to desktop.",
"steps": [
{
"id": "set_threshold",
"plugin": "value.literal",
"parameters": { "value": 240.0 },
"outputs": { "value": "screenshot_threshold" }
},
{
"id": "check_frame_240",
"plugin": "compare.gte",
"inputs": { "left": "loop.iteration", "right": "screenshot_threshold" },
"outputs": { "value": "screenshot_due" }
},
{
"id": "set_screenshot_path",
"plugin": "value.set_if",
"inputs": { "condition": "screenshot_due" },
"parameters": { "value": "/tmp/q3_screenshot.bmp" },
"outputs": { "value": "screenshot_output_path" }
},
{
"id": "stop_after_screenshot",
"plugin": "value.set_if",
"inputs": { "condition": "screenshot_due" },
"parameters": { "value": false },
"outputs": { "value": "game_running" }
},
{
"id": "quit_after_screenshot",
"plugin": "value.set_if",
"inputs": { "condition": "screenshot_due" },
"parameters": { "value": true },
"outputs": { "value": "q3.quit_requested" }
}
]
}
@@ -0,0 +1,26 @@
#include "services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3CrosshairStep::WorkflowQ3CrosshairStep(std::shared_ptr<ILogger> l) : logger_(std::move(l)) {}
std::string WorkflowQ3CrosshairStep::GetPluginId() const { return "q3.crosshair"; }
void WorkflowQ3CrosshairStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) {
if (!context.GetBool("overlay.ready", false)) return;
if (context.GetBool("q3.menu_open", false)) return;
auto* r = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
if (!r) return;
auto* ch = context.Get<SDL_Texture*>("overlay.tex.crosshair", nullptr);
constexpr float kSize = 24.f;
const float x = (kW - kSize) * 0.5f, y = (kH - kSize) * 0.5f;
if (ch) {
SDL_SetTextureAlphaMod(ch, 200); SDL_SetTextureColorMod(ch,255,255,255);
SDL_SetTextureBlendMode(ch, SDL_BLENDMODE_BLEND);
SDL_FRect dst{x, y, kSize, kSize}; SDL_RenderTexture(r, ch, nullptr, &dst);
} else {
SDL_SetRenderDrawColor(r, 255, 255, 255, 200);
SDL_FRect dot{(kW-4.f)*0.5f,(kH-4.f)*0.5f,4.f,4.f};
SDL_RenderFillRect(r, &dot);
}
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,26 @@
#include "services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
#include <cstdint>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3HitmarkerStep::WorkflowQ3HitmarkerStep(std::shared_ptr<ILogger> l) : logger_(std::move(l)) {}
std::string WorkflowQ3HitmarkerStep::GetPluginId() const { return "q3.hitmarker"; }
void WorkflowQ3HitmarkerStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) {
if (!context.GetBool("overlay.ready", false)) return;
if (context.GetBool("q3.menu_open", false)) return;
auto* r = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
if (!r) return;
const auto frame = (uint32_t)context.GetDouble("loop.iteration", 0.0);
if (frame < context.Get<uint32_t>("q3.hit_marker_until_frame", 0u)) {
SDL_SetRenderDrawColor(r, 255, 80, 55, 255);
SDL_RenderLine(r, 308,172,320,160); SDL_RenderLine(r,332,172,320,160);
SDL_RenderLine(r, 308,188,320,200); SDL_RenderLine(r,332,188,320,200);
}
if (frame < context.Get<uint32_t>("q3.weapon_flash_until_frame", 0u)) {
SDL_SetRenderDrawColor(r, 255, 200, 60, 180);
SDL_FRect flash{kW - 40.f, kH * 0.58f, 24.f, 40.f};
SDL_RenderFillRect(r, &flash);
}
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,71 @@
#include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3HudStep::WorkflowQ3HudStep(std::shared_ptr<ILogger> l) : logger_(std::move(l)) {}
std::string WorkflowQ3HudStep::GetPluginId() const { return "q3.hud"; }
void WorkflowQ3HudStep::Execute(
const WorkflowStepDefinition&, WorkflowContext& context) {
if (!context.GetBool("overlay.ready", false)) return;
if (context.GetBool("q3.menu_open", false)) return;
auto* r = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
if (!r) return;
SDL_Texture* digits[11] = {};
for (int i = 0; i < 11; ++i)
digits[i] = context.Get<SDL_Texture*>("overlay.tex.num." + std::to_string(i), nullptr);
auto* iArmor = context.Get<SDL_Texture*>("overlay.tex.icon_armor", nullptr);
auto* iFace = context.Get<SDL_Texture*>("overlay.tex.icon_face", nullptr);
auto* iWeapon = context.Get<SDL_Texture*>("overlay.tex.icon_weapon", nullptr);
const int health = context.Get<int>("q3.player_health", 100);
const int armor = context.Get<int>("q3.player_armor", 0);
const int ammo = context.Get<int>("q3.player_ammo", 50);
constexpr float kNS = 1.0f; // digit sprite scale → 32×32 px (Q3A native)
constexpr float kNH = 32.f * kNS; // digit height = 32 px
constexpr float kHudY = kH - kNH - 6.f;
auto drawIcon = [&](SDL_Texture* t, float x, float y, float w, float h) {
if (!t) return;
SDL_SetTextureAlphaMod(t, 255); SDL_SetTextureColorMod(t, 255, 255, 255);
SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND);
SDL_FRect dst{x, y, w, h}; SDL_RenderTexture(r, t, nullptr, &dst);
};
auto digitWidth = [&](int val) -> float {
return (val >= 100 ? 3 : val >= 10 ? 2 : 1) * 32.f * kNS;
};
// ── Left cluster: [armor#] [armor icon] ─────────────────────────────────
// Real Q3A: armor sits at far left, icon to its right.
float cx = 10.f;
cx = DrawHudNumber(r, digits, cx, kHudY, armor, kNS);
drawIcon(iArmor, cx + 4.f, kHudY, kNH, kNH);
// ── Center cluster: [health#] [face icon] ───────────────────────────────
// Real Q3A: health number + mugshot centered on screen.
{
const float hNumW = digitWidth(health);
const float cluster = hNumW + 4.f + kNH;
const float hx = kW * 0.5f - cluster * 0.5f;
DrawHudNumber(r, digits, hx, kHudY, health, kNS);
drawIcon(iFace, hx + hNumW + 4.f, kHudY, kNH, kNH);
}
// ── Right cluster: [ammo#] [weapon icon] ─────────────────────────────────
// Real Q3A: ammo number then weapon icon at far-right edge.
{
const float wIconW = iWeapon ? kNH : 0.f;
const float aNumW = digitWidth(ammo);
const float rx = kW - 10.f - wIconW - (wIconW > 0 ? 6.f : 0.f) - aNumW;
DrawHudNumber(r, digits, rx, kHudY, ammo, kNS);
drawIcon(iWeapon, rx + aNumW + 6.f, kHudY, wIconW, wIconW);
}
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,117 @@
#include "services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <nlohmann/json.hpp>
#include <SDL3/SDL_render.h>
#include <algorithm>
#include <cctype>
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3MapSelectStep::WorkflowQ3MapSelectStep(std::shared_ptr<ILogger> l) : logger_(std::move(l)) {}
std::string WorkflowQ3MapSelectStep::GetPluginId() const { return "q3.mapselect"; }
void WorkflowQ3MapSelectStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) {
if (!context.GetBool("overlay.ready", false)) return;
if (!context.GetBool("q3.menu_open", false)) return;
if (context.Get<std::string>("q3.menu_screen","main") != "map_select") return;
auto* r = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
auto* prop = context.Get<SDL_Texture*>("overlay.tex.prop", nullptr);
auto* bchars = context.Get<SDL_Texture*>("overlay.tex.bigchars", nullptr);
if (!r) return;
auto arenas = context.Get<std::shared_ptr<ArenaMap>>("overlay.arena_data", nullptr);
auto shots = context.Get<std::shared_ptr<LevelshotCache>>("overlay.levelshot_cache", nullptr);
const auto bspCfg = context.Get<nlohmann::json>("bsp_config", nlohmann::json{});
const std::string pk3 = bspCfg.value("pk3_path", std::string(""));
// Black background
SDL_SetRenderDrawColor(r, 0, 0, 0, 255);
SDL_FRect bg{0,0,(float)kW,(float)kH}; SDL_RenderFillRect(r, &bg);
constexpr float kCX = kW * 0.5f;
DrawQ3Text(r, bchars, kCX-96.f, 12.f, "CHOOSE LEVEL", {200,50,20,255}, 1.0f);
const auto maps = context.Get<nlohmann::json>("q3.maps", nlohmann::json::array({"q3dm7"}));
const int sel = context.Get<int>("q3.menu_selected_item", 0);
const int nMaps = (int)maps.size();
const int idx = (nMaps>0) ? std::max(0,std::min(sel,nMaps-1)) : 0;
std::string mapName = (nMaps>0) ? maps[(size_t)idx].get<std::string>() : "q3dm7";
// Levelshot
constexpr float kLW=162.f, kLH=162.f, kLX=(kW-kLW)*0.5f, kLY=58.f;
SDL_Texture* shot = nullptr;
if (shots && !mapName.empty() && !pk3.empty()) {
std::string key = mapName;
std::transform(key.begin(),key.end(),key.begin(),::toupper);
auto it = shots->find(key);
if (it != shots->end()) { shot = it->second; }
else {
shot = LoadTextureFromPk3(r, pk3, ("levelshots/"+key+".jpg").c_str());
if (!shot) shot = LoadTextureFromPk3(r, pk3, ("levelshots/"+mapName+".jpg").c_str());
(*shots)[key] = shot;
}
}
if (shot) {
SDL_FRect dst{kLX,kLY,kLW,kLH}; SDL_SetTextureAlphaMod(shot,255);
SDL_SetTextureBlendMode(shot,SDL_BLENDMODE_NONE);
SDL_RenderTexture(r,shot,nullptr,&dst);
} else {
SDL_SetRenderDrawColor(r,30,20,15,255); SDL_FRect fb{kLX,kLY,kLW,kLH}; SDL_RenderFillRect(r,&fb);
}
SDL_SetRenderDrawColor(r,180,50,20,255);
SDL_FRect border{kLX-2.f,kLY-2.f,kLW+4.f,kLH+4.f}; SDL_RenderRect(r,&border);
std::string mU=mapName; std::transform(mU.begin(),mU.end(),mU.begin(),::toupper);
DrawPropText(r,prop,kCX,kLY+kLH+6.f,mU.c_str(),{255,200,50,255},0.7f,true);
std::string mKey=mapName; std::transform(mKey.begin(),mKey.end(),mKey.begin(),::tolower);
if (arenas) {
auto aIt = arenas->find(mKey);
if (aIt != arenas->end() && !aIt->second.longname.empty()) {
std::string ln = mU+": "+aIt->second.longname;
std::transform(ln.begin(),ln.end(),ln.begin(),::toupper);
DrawPropText(r,prop,kCX,kLY+kLH+34.f,ln.c_str(),{200,130,40,255},0.75f,true);
if (!aIt->second.bot.empty() && shots && !pk3.empty()) {
const std::string botKey=aIt->second.bot;
std::string bk=botKey; std::transform(bk.begin(),bk.end(),bk.begin(),::tolower);
SDL_Texture* botIcon = nullptr;
auto bIt = shots->find("bot_"+bk);
if (bIt!=shots->end()) { botIcon=bIt->second; }
else {
botIcon=LoadTextureFromPk3(r,pk3,("models/players/"+bk+"/icon_default.tga").c_str());
(*shots)["bot_"+bk]=botIcon;
}
if (botIcon) {
SDL_FRect dst{kCX-22.f,kLY+kLH+76.f,44.f,44.f};
SDL_SetTextureAlphaMod(botIcon,255); SDL_SetTextureBlendMode(botIcon,SDL_BLENDMODE_BLEND);
SDL_RenderTexture(r,botIcon,nullptr,&dst);
}
std::string bU=botKey; std::transform(bU.begin(),bU.end(),bU.begin(),::toupper);
DrawPropText(r,prop,kCX,kLY+kLH+124.f,bU.c_str(),{180,130,50,220},0.65f,true);
}
}
}
// Nav arrows
auto* al = context.Get<SDL_Texture*>("overlay.tex.arrow_l", nullptr);
auto* ar = context.Get<SDL_Texture*>("overlay.tex.arrow_r", nullptr);
constexpr float kAY = kLY+kLH*0.5f-16.f;
if (al && idx>0) { SDL_FRect d{8.f,kAY,64.f,32.f}; SDL_RenderTexture(r,al,nullptr,&d); }
if (ar && idx<nMaps-1) { SDL_FRect d{kW-72.f,kAY,64.f,32.f}; SDL_RenderTexture(r,ar,nullptr,&d); }
// Buttons
auto* bb = context.Get<SDL_Texture*>("overlay.tex.btn_back", nullptr);
auto* bf = context.Get<SDL_Texture*>("overlay.tex.btn_fight", nullptr);
auto* bs = context.Get<SDL_Texture*>("overlay.tex.btn_skirmish", nullptr);
constexpr float kBY=(float)kH-52.f, kBW=128.f, kBH=46.f;
auto dbtn=[&](SDL_Texture* t,float x){
if(!t) return; SDL_SetTextureAlphaMod(t,255); SDL_SetTextureBlendMode(t,SDL_BLENDMODE_BLEND);
SDL_FRect d{x,kBY,kBW,kBH}; SDL_RenderTexture(r,t,nullptr,&d);
};
dbtn(bb, kW*0.12f-kBW*0.5f);
dbtn(bs, kW*0.50f-kBW*0.5f);
dbtn(bf, kW*0.88f-kBW*0.5f);
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,60 @@
#include "services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <nlohmann/json.hpp>
#include <SDL3/SDL_render.h>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowQ3MenuFrameStep::WorkflowQ3MenuFrameStep(std::shared_ptr<ILogger> l) : logger_(std::move(l)) {}
std::string WorkflowQ3MenuFrameStep::GetPluginId() const { return "q3.menu.frame"; }
void WorkflowQ3MenuFrameStep::Execute(const WorkflowStepDefinition&, WorkflowContext& context) {
if (!context.GetBool("overlay.ready", false)) return;
if (!context.GetBool("q3.menu_open", false)) return;
const std::string screen = context.Get<std::string>("q3.menu_screen", "main");
if (screen == "map_select") return; // handled by q3.mapselect
auto* r = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
auto* prop = context.Get<SDL_Texture*>("overlay.tex.prop", nullptr);
auto* glo = context.Get<SDL_Texture*>("overlay.tex.prop_glo", nullptr);
auto* fl = context.Get<SDL_Texture*>("overlay.tex.frame_l", nullptr);
auto* fr = context.Get<SDL_Texture*>("overlay.tex.frame_r", nullptr);
if (!r) return;
// Black background
SDL_SetRenderDrawColor(r, 0, 0, 0, 255);
SDL_FRect bg{0,0,(float)kW,(float)kH}; SDL_RenderFillRect(r, &bg);
// Ring emblem
constexpr float kRY=120.f, kRH=185.f, kHalf=kW*0.5f;
if (fl) { SDL_FRect d{0.f,kRY,kHalf,kRH}; SDL_SetTextureAlphaMod(fl,255); SDL_RenderTexture(r,fl,nullptr,&d); }
if (fr) { SDL_FRect d{kHalf,kRY,kHalf,kRH}; SDL_SetTextureAlphaMod(fr,255); SDL_RenderTexture(r,fr,nullptr,&d); }
// Title with glow
constexpr float kCX=kW*0.5f, kTY=20.f, kTS=1.5f, kSS=1.1f;
const float kSY = kTY + kPropHeight*kTS + 2.f;
if (glo) {
DrawPropText(r,glo,kCX+2.f,kTY+2.f,"QUAKE III",{255,80,0,120},kTS,true);
DrawPropText(r,glo,kCX+2.f,kSY+2.f,"ARENA", {255,80,0,120},kSS,true);
}
DrawPropText(r,prop,kCX,kTY,"QUAKE III",{255,200,50,255},kTS,true);
DrawPropText(r,prop,kCX,kSY,"ARENA", {220,100,10,255},kSS,true);
// Menu items
const auto items = context.Get<nlohmann::json>("q3.menu_items", nlohmann::json::array());
const int sel = context.Get<int>("q3.menu_selected_item", 0);
const int nItems = (int)items.size();
constexpr float kIS=1.0f, kIStep=kPropHeight*kIS+8.f, kITop=185.f;
for (int i=0; i<nItems; ++i) {
const std::string lbl = items[i].value("label","");
const float iy = kITop + i*kIStep;
if (i==sel) {
if (glo) { DrawPropText(r,glo,kCX-1.f,iy,lbl.c_str(),{255,180,0,100},kIS,true);
DrawPropText(r,glo,kCX+1.f,iy,lbl.c_str(),{255,180,0,100},kIS,true); }
DrawPropText(r,prop,kCX,iy,lbl.c_str(),{255,220,80,255},kIS,true);
} else {
DrawPropText(r,prop,kCX,iy,lbl.c_str(),{200,55,35,210},kIS,true);
}
}
DrawPropText(r,prop,kCX,(float)(kH-18),
"Quake III Arena(c) 1999-2000, Id Software, Inc.",{180,50,30,200},0.42f,true);
}
} // namespace sdl3cpp::services::impl
@@ -1,885 +0,0 @@
#include "services/interfaces/workflow/quake3/workflow_q3_overlay_draw_step.hpp"
#include <SDL3/SDL.h>
#include <nlohmann/json.hpp>
#include <stb_image.h>
#include <zip.h>
#include <algorithm>
#include <cctype>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
namespace {
std::vector<uint8_t> LoadBinary(const char* path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f.is_open()) return {};
auto size = f.tellg();
std::vector<uint8_t> data(static_cast<size_t>(size));
f.seekg(0);
f.read(reinterpret_cast<char*>(data.data()), size);
return data;
}
} // namespace
// ---------------------------------------------------------------------------
// Construction / destruction
// ---------------------------------------------------------------------------
WorkflowQ3OverlayDrawStep::WorkflowQ3OverlayDrawStep(std::shared_ptr<ILogger> logger)
: logger_(std::move(logger)) {}
WorkflowQ3OverlayDrawStep::~WorkflowQ3OverlayDrawStep() {
if (renderer_) {
if (bigchars_tex_) SDL_DestroyTexture(bigchars_tex_);
if (prop_font_tex_) SDL_DestroyTexture(prop_font_tex_);
if (prop_glo_tex_) SDL_DestroyTexture(prop_glo_tex_);
if (frame_bg_tex_) SDL_DestroyTexture(frame_bg_tex_);
if (frame_l_tex_) SDL_DestroyTexture(frame_l_tex_);
if (frame_r_tex_) SDL_DestroyTexture(frame_r_tex_);
if (frame2_l_tex_) SDL_DestroyTexture(frame2_l_tex_);
for (auto* t : num_digits_) if (t) SDL_DestroyTexture(t);
if (icon_armor_tex_) SDL_DestroyTexture(icon_armor_tex_);
if (icon_health_tex_) SDL_DestroyTexture(icon_health_tex_);
if (icon_face_tex_) SDL_DestroyTexture(icon_face_tex_);
if (icon_ammo_tex_) SDL_DestroyTexture(icon_ammo_tex_);
if (icon_crosshair_) SDL_DestroyTexture(icon_crosshair_);
if (btn_back_tex_) SDL_DestroyTexture(btn_back_tex_);
if (btn_fight_tex_) SDL_DestroyTexture(btn_fight_tex_);
if (btn_skirmish_tex_)SDL_DestroyTexture(btn_skirmish_tex_);
if (btn_arrow_l_tex_) SDL_DestroyTexture(btn_arrow_l_tex_);
if (btn_arrow_r_tex_) SDL_DestroyTexture(btn_arrow_r_tex_);
for (auto& [k, v] : levelshot_cache_) if (v) SDL_DestroyTexture(v);
SDL_DestroyRenderer(renderer_);
}
if (surface_) SDL_DestroySurface(surface_);
if (device_) {
if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_);
if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_);
if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_);
if (tex_) SDL_ReleaseGPUTexture(device_, tex_);
if (pipeline_) SDL_ReleaseGPUGraphicsPipeline(device_, pipeline_);
}
}
std::string WorkflowQ3OverlayDrawStep::GetPluginId() const {
return "q3.overlay.draw";
}
// ---------------------------------------------------------------------------
// GPU pipeline init (unchanged from before)
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::TryInit(SDL_GPUDevice* device, SDL_Window* window) {
if (disabled_ || ready_) return;
device_ = device;
const char* driver = SDL_GetGPUDeviceDriver(device);
const std::string driverName = driver ? driver : "";
SDL_GPUShaderFormat shaderFormat = SDL_GPU_SHADERFORMAT_INVALID;
std::vector<uint8_t> vert, frag;
const char* entry = "main";
if (driverName == "metal") {
shaderFormat = SDL_GPU_SHADERFORMAT_MSL;
vert = LoadBinary("packages/quake3/shaders/msl/overlay.vert.metal");
frag = LoadBinary("packages/quake3/shaders/msl/overlay.frag.metal");
entry = "main0";
} else if (driverName == "vulkan") {
shaderFormat = SDL_GPU_SHADERFORMAT_SPIRV;
vert = LoadBinary("packages/quake3/shaders/spirv/overlay.vert.spv");
frag = LoadBinary("packages/quake3/shaders/spirv/overlay.frag.spv");
} else {
disabled_ = true; return;
}
if (vert.empty() || frag.empty()) { disabled_ = true; return; }
SDL_GPUShaderCreateInfo vsi = {};
vsi.code = vert.data(); vsi.code_size = vert.size();
vsi.entrypoint = entry; vsi.format = shaderFormat;
vsi.stage = SDL_GPU_SHADERSTAGE_VERTEX;
SDL_GPUShaderCreateInfo fsi = {};
fsi.code = frag.data(); fsi.code_size = frag.size();
fsi.entrypoint = entry; fsi.format = shaderFormat;
fsi.stage = SDL_GPU_SHADERSTAGE_FRAGMENT;
fsi.num_samplers = 1;
auto* vs = SDL_CreateGPUShader(device, &vsi);
auto* fs = SDL_CreateGPUShader(device, &fsi);
if (!vs || !fs) {
if (vs) SDL_ReleaseGPUShader(device, vs);
if (fs) SDL_ReleaseGPUShader(device, fs);
disabled_ = true; return;
}
SDL_GPUVertexBufferDescription vbd = {};
vbd.slot = 0; vbd.pitch = sizeof(float) * 5;
vbd.input_rate = SDL_GPU_VERTEXINPUTRATE_VERTEX;
SDL_GPUVertexAttribute attrs[2] = {};
attrs[0] = {0, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, 0};
attrs[1] = {1, 0, SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, sizeof(float) * 3};
SDL_GPUVertexInputState vis = {};
vis.vertex_buffer_descriptions = &vbd; vis.num_vertex_buffers = 1;
vis.vertex_attributes = attrs; vis.num_vertex_attributes = 2;
SDL_GPUColorTargetDescription ctd = {};
ctd.format = window
? SDL_GetGPUSwapchainTextureFormat(device, window)
: SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM;
ctd.blend_state.enable_blend = true;
ctd.blend_state.src_color_blendfactor = SDL_GPU_BLENDFACTOR_SRC_ALPHA;
ctd.blend_state.dst_color_blendfactor = SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
ctd.blend_state.color_blend_op = SDL_GPU_BLENDOP_ADD;
ctd.blend_state.src_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ONE;
ctd.blend_state.dst_alpha_blendfactor = SDL_GPU_BLENDFACTOR_ZERO;
ctd.blend_state.alpha_blend_op = SDL_GPU_BLENDOP_ADD;
SDL_GPUGraphicsPipelineCreateInfo pci = {};
pci.vertex_shader = vs; pci.fragment_shader = fs;
pci.vertex_input_state = vis;
pci.primitive_type = SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
pci.rasterizer_state.fill_mode = SDL_GPU_FILLMODE_FILL;
pci.rasterizer_state.cull_mode = SDL_GPU_CULLMODE_NONE;
pci.rasterizer_state.front_face = SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE;
pci.depth_stencil_state.enable_depth_test = false;
pci.depth_stencil_state.enable_depth_write = false;
pci.target_info.num_color_targets = 1;
pci.target_info.color_target_descriptions = &ctd;
pipeline_ = SDL_CreateGPUGraphicsPipeline(device, &pci);
SDL_ReleaseGPUShader(device, vs);
SDL_ReleaseGPUShader(device, fs);
if (!pipeline_) { disabled_ = true; return; }
SDL_GPUTextureCreateInfo tci = {};
tci.type = SDL_GPU_TEXTURETYPE_2D;
tci.format = SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
tci.width = kW; tci.height = kH;
tci.layer_count_or_depth = 1; tci.num_levels = 1;
tci.usage = SDL_GPU_TEXTUREUSAGE_SAMPLER;
tex_ = SDL_CreateGPUTexture(device, &tci);
SDL_GPUTransferBufferCreateInfo tbci = {};
tbci.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD;
tbci.size = kW * kH * 4;
transfer_ = SDL_CreateGPUTransferBuffer(device, &tbci);
SDL_GPUBufferCreateInfo bci = {};
bci.usage = SDL_GPU_BUFFERUSAGE_VERTEX;
bci.size = 6u * 5u * static_cast<uint32_t>(sizeof(float));
vtx_buf_ = SDL_CreateGPUBuffer(device, &bci);
SDL_GPUSamplerCreateInfo sci = {};
sci.min_filter = SDL_GPU_FILTER_NEAREST;
sci.mag_filter = SDL_GPU_FILTER_NEAREST;
sci.mipmap_mode = SDL_GPU_SAMPLERMIPMAPMODE_NEAREST;
sci.address_mode_u = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
sci.address_mode_v = SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
sampler_ = SDL_CreateGPUSampler(device, &sci);
surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32);
renderer_ = surface_ ? SDL_CreateSoftwareRenderer(surface_) : nullptr;
ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && surface_ && renderer_;
}
// ---------------------------------------------------------------------------
// Load a texture from inside a PK3 (zip) archive using stb_image
// ---------------------------------------------------------------------------
SDL_Texture* WorkflowQ3OverlayDrawStep::LoadTextureFromPk3(
const std::string& pk3Path, const char* entry) {
if (pk3Path.empty() || !renderer_) return nullptr;
int zip_err = 0;
zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &zip_err);
if (!arc) return nullptr;
zip_stat_t st;
if (zip_stat(arc, entry, 0, &st) != 0) { zip_close(arc); return nullptr; }
std::vector<uint8_t> buf(st.size);
zip_file_t* zf = zip_fopen(arc, entry, 0);
if (!zf) { zip_close(arc); return nullptr; }
zip_fread(zf, buf.data(), st.size);
zip_fclose(zf);
zip_close(arc);
int w = 0, h = 0, ch = 0;
unsigned char* px = stbi_load_from_memory(buf.data(),
static_cast<int>(buf.size()), &w, &h, &ch, 4);
if (!px) return nullptr;
SDL_Surface* surf = SDL_CreateSurfaceFrom(
w, h, SDL_PIXELFORMAT_RGBA32, px, w * 4);
SDL_Texture* tex = surf ? SDL_CreateTextureFromSurface(renderer_, surf) : nullptr;
if (surf) SDL_DestroySurface(surf);
stbi_image_free(px);
return tex;
}
// ---------------------------------------------------------------------------
// Lazy-load all menu textures from the same PK3 as the current BSP
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::TryLoadMenuTextures(const std::string& pk3Path) {
if (menu_tex_loaded_) return;
menu_tex_loaded_ = true; // set early so we don't retry on failure
bigchars_tex_ = LoadTextureFromPk3(pk3Path, "gfx/2d/bigchars.tga");
prop_font_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/font1_prop.tga");
prop_glo_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/font1_prop_glo.tga");
frame_bg_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/cut_frame.tga");
frame_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_l.tga");
frame_r_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame1_r.tga");
frame2_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/frame2_l.tga");
// HUD large-number sprites (gfx/2d/numbers/*_32b.tga, each 32x32)
static const char* kDigitNames[11] = {
"gfx/2d/numbers/zero_32b.tga", "gfx/2d/numbers/one_32b.tga",
"gfx/2d/numbers/two_32b.tga", "gfx/2d/numbers/three_32b.tga",
"gfx/2d/numbers/four_32b.tga", "gfx/2d/numbers/five_32b.tga",
"gfx/2d/numbers/six_32b.tga", "gfx/2d/numbers/seven_32b.tga",
"gfx/2d/numbers/eight_32b.tga", "gfx/2d/numbers/nine_32b.tga",
"gfx/2d/numbers/minus_32b.tga"
};
for (int i = 0; i < 11; ++i)
num_digits_[i] = LoadTextureFromPk3(pk3Path, kDigitNames[i]);
// HUD icon sprites
icon_armor_tex_ = LoadTextureFromPk3(pk3Path, "icons/iconr_yellow.tga");
icon_health_tex_ = LoadTextureFromPk3(pk3Path, "icons/iconh_red.tga");
icon_face_tex_ = LoadTextureFromPk3(pk3Path, "models/players/keel/icon_default.tga");
icon_ammo_tex_ = LoadTextureFromPk3(pk3Path, "icons/icona_machinegun.tga");
icon_crosshair_ = LoadTextureFromPk3(pk3Path, "gfx/2d/crosshaira.tga");
// Map-select buttons and nav arrows
btn_back_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/back_0.tga");
btn_fight_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/fight_0.tga");
btn_skirmish_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/skirmish_0.tga");
btn_arrow_l_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/gs_arrows_l.tga");
btn_arrow_r_tex_ = LoadTextureFromPk3(pk3Path, "menu/art/gs_arrows_r.tga");
ParseArenas(pk3Path);
if (logger_) {
logger_->Info(std::string("q3.overlay: textures loaded — "
"nums:") + (num_digits_[0] ? "ok" : "MISS") +
" armor:" + (icon_armor_tex_ ? "ok" : "MISS") +
" face:" + (icon_face_tex_ ? "ok" : "MISS") +
" fight:" + (btn_fight_tex_ ? "ok" : "MISS") +
" arenas:" + std::to_string(arena_data_.size()));
}
}
// ---------------------------------------------------------------------------
// Parse scripts/arenas.txt from the PK3 for map long-names and bot opponents
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::ParseArenas(const std::string& pk3Path) {
if (arena_data_loaded_) return;
arena_data_loaded_ = true;
int err = 0;
zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &err);
if (!arc) return;
zip_stat_t st;
if (zip_stat(arc, "scripts/arenas.txt", 0, &st) != 0) { zip_close(arc); return; }
std::vector<char> buf(st.size + 1, '\0');
zip_file_t* zf = zip_fopen(arc, "scripts/arenas.txt", 0);
if (zf) { zip_fread(zf, buf.data(), st.size); zip_fclose(zf); }
zip_close(arc);
// Simple tokenizer: split on '{' '}' and parse key/value pairs
std::string text(buf.data());
size_t pos = 0;
while ((pos = text.find('{', pos)) != std::string::npos) {
size_t end = text.find('}', pos);
if (end == std::string::npos) break;
std::string block = text.substr(pos + 1, end - pos - 1);
pos = end + 1;
// Extract key-value pairs (Q3 config format: key "value")
std::string mapName, longName, bots;
std::istringstream ss(block);
std::string tok;
while (ss >> tok) {
auto readVal = [&]() -> std::string {
std::string val;
ss >> std::ws;
if (ss.peek() == '"') {
ss.get();
std::getline(ss, val, '"');
} else { ss >> val; }
return val;
};
if (tok == "map") mapName = readVal();
else if (tok == "longname") longName = readVal();
else if (tok == "bots") bots = readVal();
}
if (!mapName.empty()) {
// Store under lowercase key
std::string key = mapName;
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
// First bot only
std::string firstBot = bots.substr(0, bots.find(' '));
arena_data_[key] = { longName, firstBot };
}
}
}
// ---------------------------------------------------------------------------
// Load or retrieve a cached levelshot texture for a map name
// ---------------------------------------------------------------------------
SDL_Texture* WorkflowQ3OverlayDrawStep::LoadOrGetLevelshot(
const std::string& mapName, const std::string& pk3Path) {
// Levelshot paths use uppercase names (e.g. levelshots/Q3DM7.jpg)
std::string key = mapName;
std::transform(key.begin(), key.end(), key.begin(), ::toupper);
auto it = levelshot_cache_.find(key);
if (it != levelshot_cache_.end()) return it->second;
// Try uppercase then lowercase
std::string upperPath = "levelshots/" + key + ".jpg";
std::string lowerPath = "levelshots/" + mapName + ".jpg";
SDL_Texture* tex = LoadTextureFromPk3(pk3Path, upperPath.c_str());
if (!tex) tex = LoadTextureFromPk3(pk3Path, lowerPath.c_str());
levelshot_cache_[key] = tex;
return tex;
}
// ---------------------------------------------------------------------------
// Draw an integer using the number sprites; returns x position after last digit
// ---------------------------------------------------------------------------
float WorkflowQ3OverlayDrawStep::DrawHudNumber(
float x, float y, int value, float scale) {
if (!renderer_) return x;
// Convert to string; use minus sprite for negative values
std::string s;
if (value < 0) { s = "-"; s += std::to_string(-value); }
else { s = std::to_string(value); }
const float dw = 32.f * scale;
float cx = x;
for (char c : s) {
int idx = (c == '-') ? 10 : (c - '0');
if (idx < 0 || idx > 10) { cx += dw; continue; }
if (SDL_Texture* t = num_digits_[idx]) {
SDL_SetTextureColorMod(t, 255, 220, 60); // Q3A gold tint
SDL_SetTextureAlphaMod(t, 255);
SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND);
SDL_FRect dst{cx, y, dw, dw};
SDL_RenderTexture(renderer_, t, nullptr, &dst);
}
cx += dw;
}
return cx;
}
// ---------------------------------------------------------------------------
// Q3 bitmap-font text renderer
// In bigchars.tga each ASCII character N occupies the cell:
// col = N % 16, row = N / 16 (16 columns × 16 rows, each cell 16×16 px)
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::DrawQ3Text(
float x, float y, const char* text, SDL_Color color, float scale) {
if (!text || !renderer_) return;
if (bigchars_tex_) {
SDL_SetTextureColorMod(bigchars_tex_, color.r, color.g, color.b);
SDL_SetTextureAlphaMod(bigchars_tex_, color.a);
SDL_SetTextureBlendMode(bigchars_tex_, SDL_BLENDMODE_BLEND);
const float cw = kGlyphSrc * scale;
const float ch = kGlyphSrc * scale;
float cx = x;
for (const char* p = text; *p; ++p) {
const int code = static_cast<unsigned char>(*p);
SDL_FRect src = { static_cast<float>((code % 16) * kGlyphSrc),
static_cast<float>((code / 16) * kGlyphSrc),
static_cast<float>(kGlyphSrc),
static_cast<float>(kGlyphSrc) };
SDL_FRect dst = { cx, y, cw, ch };
SDL_RenderTexture(renderer_, bigchars_tex_, &src, &dst);
cx += cw;
}
} else {
// Fallback: built-in debug text
SDL_SetRenderDrawColor(renderer_, color.r, color.g, color.b, color.a);
SDL_RenderDebugText(renderer_, x, y, text);
}
}
// ---------------------------------------------------------------------------
// Q3 proportional font renderer (font1_prop.tga)
// Source: ioquake3 code/q3_ui/ui_atoms.c propMap[128][3] = {src_x, src_y, width}
// PROP_HEIGHT=27, PROP_GAP_WIDTH=3, PROP_SPACE_WIDTH=8
// ---------------------------------------------------------------------------
static const int kPropMap[128][3] = {
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
// 32 space
{0, 0, 8},
// 33 ! 34 " 35 # 36 $ 37 % 38 & 39 '
{11,122,7},{154,181,14},{55,122,17},{79,122,18},{101,122,23},{153,122,18},{9,93,7},
// 40 ( 41 ) 42 * 43 + 44 , 45 - 46 . 47 /
{207,122,8},{230,122,9},{177,122,18},{30,152,18},{85,181,7},{34,93,11},{110,181,6},{130,152,14},
// 48-57: 0-9
{22,64,17},{41,64,12},{58,64,17},{78,64,18},{98,64,19},{120,64,18},{141,64,18},{204,64,16},{162,64,17},{182,64,18},
// 58 : 59 ; 60 < 61 = 62 > 63 ? 64 @
{59,181,7},{35,181,7},{203,152,14},{56,93,14},{228,152,14},{177,181,18},{28,122,22},
// 65-90: A-Z
{5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17},
{164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18},
{68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29},
{215,34,18},{234,34,18},{5,64,14},
// 91 [ 92 \ 93 ] 94 ^ 95 _ 96 `
{60,152,7},{106,151,13},{83,152,7},{128,122,17},{4,152,21},{134,181,5},
// 97-122: a-z (map to uppercase in prop font)
{5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17},
{164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18},
{68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29},
{215,34,18},{234,34,18},{5,64,14},
// 123 { 124 | 125 } 126 ~ 127 DEL
{153,152,13},{11,181,5},{180,152,13},{79,93,17},{0,0,-1}
};
float WorkflowQ3OverlayDrawStep::PropStringWidth(const char* text) const {
if (!text) return 0.f;
float w = 0.f;
for (const char* p = text; *p; ++p) {
const int ch = static_cast<unsigned char>(*p) & 127;
const int cw = kPropMap[ch][2];
if (cw == -1) continue;
w += static_cast<float>(cw == 8 ? 8 : cw + kPropGap);
}
return w - kPropGap; // no trailing gap
}
void WorkflowQ3OverlayDrawStep::DrawPropText(
float x, float y, const char* text, SDL_Color color, float scale, bool center) {
if (!text || !renderer_) return;
SDL_Texture* fnt = prop_font_tex_ ? prop_font_tex_ : nullptr;
if (!fnt) {
// Fallback to debug text if prop font failed to load
SDL_SetRenderDrawColor(renderer_, color.r, color.g, color.b, color.a);
SDL_RenderDebugText(renderer_, x, y, text);
return;
}
if (center)
x -= PropStringWidth(text) * scale * 0.5f;
SDL_SetTextureColorMod(fnt, color.r, color.g, color.b);
SDL_SetTextureAlphaMod(fnt, color.a);
SDL_SetTextureBlendMode(fnt, SDL_BLENDMODE_BLEND);
float cx = x;
for (const char* p = text; *p; ++p) {
const int ch = static_cast<unsigned char>(*p) & 127;
const int cw = kPropMap[ch][2];
if (cw == -1) continue;
if (cw == kPropSpace) { cx += kPropSpace * scale; continue; }
SDL_FRect src = { static_cast<float>(kPropMap[ch][0]),
static_cast<float>(kPropMap[ch][1]),
static_cast<float>(cw),
static_cast<float>(kPropHeight) };
SDL_FRect dst = { cx, y, cw * scale, kPropHeight * scale };
SDL_RenderTexture(renderer_, fnt, &src, &dst);
cx += (cw + kPropGap) * scale;
}
}
// ---------------------------------------------------------------------------
// Q3A "Choose Level" / map-select screen
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::DrawMapSelectScreen(
WorkflowContext& context, const std::string& pk3Path) {
constexpr float kCX = kW * 0.5f;
// ── "CHOOSE LEVEL" title ─────────────────────────────────────────────
// Q3A uses bigchars font at large scale for this heading.
DrawQ3Text(kCX - 96.f, 12.f, "CHOOSE LEVEL", {200, 50, 20, 255}, 1.0f);
// ── Determine which map is selected and load its levelshot ───────────
const auto maps = context.Get<nlohmann::json>("q3.maps", nlohmann::json::array({"q3dm7"}));
const int sel = context.Get<int>("q3.menu_selected_item", 0);
const int nMaps = static_cast<int>(maps.size());
const int idx = (nMaps > 0) ? std::max(0, std::min(sel, nMaps-1)) : 0;
std::string mapName;
if (nMaps > 0) mapName = maps[(size_t)idx].get<std::string>();
// Levelshot image — centred, bordered in Q3A orange-red
constexpr float kLW = 162.f, kLH = 162.f;
constexpr float kLX = (kW - kLW) * 0.5f;
constexpr float kLY = 58.f;
SDL_Texture* shot = (!mapName.empty() && !pk3Path.empty())
? LoadOrGetLevelshot(mapName, pk3Path) : nullptr;
if (shot) {
SDL_FRect dst{kLX, kLY, kLW, kLH};
SDL_SetTextureAlphaMod(shot, 255);
SDL_SetTextureColorMod(shot, 255, 255, 255);
SDL_SetTextureBlendMode(shot, SDL_BLENDMODE_NONE);
SDL_RenderTexture(renderer_, shot, nullptr, &dst);
} else {
SDL_SetRenderDrawColor(renderer_, 30, 20, 15, 255);
SDL_FRect fbk{kLX, kLY, kLW, kLH};
SDL_RenderFillRect(renderer_, &fbk);
}
// Red border
SDL_SetRenderDrawColor(renderer_, 180, 50, 20, 255);
SDL_FRect border{kLX - 2.f, kLY - 2.f, kLW + 4.f, kLH + 4.f};
SDL_RenderRect(renderer_, &border);
// Map name label under image (e.g. "Q3DM7")
std::string mapUpper = mapName;
std::transform(mapUpper.begin(), mapUpper.end(), mapUpper.begin(), ::toupper);
DrawPropText(kCX, kLY + kLH + 6.f, mapUpper.c_str(), {255, 200, 50, 255}, 0.7f, true);
// Long name from arenas.txt (e.g. "TEMPLE OF RETRIBUTION")
std::string mapKey = mapName;
std::transform(mapKey.begin(), mapKey.end(), mapKey.begin(), ::tolower);
auto aIt = arena_data_.find(mapKey);
if (aIt != arena_data_.end() && !aIt->second.longname.empty()) {
std::string longUpper = mapUpper + ": " + aIt->second.longname;
std::transform(longUpper.begin(), longUpper.end(), longUpper.begin(), ::toupper);
DrawPropText(kCX, kLY + kLH + 34.f, longUpper.c_str(), {200, 130, 40, 255}, 0.75f, true);
}
// ── Opponent bot icon + name ─────────────────────────────────────────
float botY = kLY + kLH + 76.f;
if (aIt != arena_data_.end() && !aIt->second.bot.empty()) {
const std::string botName = aIt->second.bot;
std::string botKey = botName;
std::transform(botKey.begin(), botKey.end(), botKey.begin(), ::tolower);
const std::string iconPath = "models/players/" + botKey + "/icon_default.tga";
SDL_Texture* botIcon = LoadOrGetLevelshot("bot_" + botKey, pk3Path);
if (!botIcon) {
// Try loading from pk3 directly (not a levelshot — use raw loader)
botIcon = LoadTextureFromPk3(pk3Path, iconPath.c_str());
if (botIcon) levelshot_cache_["bot_" + botKey] = botIcon;
}
if (botIcon) {
SDL_FRect dst{kCX - 22.f, botY, 44.f, 44.f};
SDL_SetTextureAlphaMod(botIcon, 255);
SDL_SetTextureColorMod(botIcon, 255, 255, 255);
SDL_SetTextureBlendMode(botIcon, SDL_BLENDMODE_BLEND);
SDL_RenderTexture(renderer_, botIcon, nullptr, &dst);
}
std::string botUpper = botName;
std::transform(botUpper.begin(), botUpper.end(), botUpper.begin(), ::toupper);
DrawPropText(kCX, botY + 48.f, botUpper.c_str(), {180, 130, 50, 220}, 0.65f, true);
botY += 80.f;
}
// ── Navigation arrows (left / right) ─────────────────────────────────
constexpr float kArrowY = kLY + kLH * 0.5f - 16.f;
if (btn_arrow_l_tex_ && idx > 0) {
SDL_FRect dst{8.f, kArrowY, 64.f, 32.f};
SDL_RenderTexture(renderer_, btn_arrow_l_tex_, nullptr, &dst);
}
if (btn_arrow_r_tex_ && idx < nMaps - 1) {
SDL_FRect dst{kW - 72.f, kArrowY, 64.f, 32.f};
SDL_RenderTexture(renderer_, btn_arrow_r_tex_, nullptr, &dst);
}
// ── Bottom buttons: BACK SKIRMISH FIGHT ────────────────────────────
constexpr float kBtnY = static_cast<float>(kH) - 52.f;
constexpr float kBtnW = 128.f, kBtnH = 46.f;
auto drawBtn = [&](SDL_Texture* t, float x) {
if (!t) return;
SDL_SetTextureAlphaMod(t, 255);
SDL_SetTextureColorMod(t, 255, 255, 255);
SDL_SetTextureBlendMode(t, SDL_BLENDMODE_BLEND);
SDL_FRect dst{x, kBtnY, kBtnW, kBtnH};
SDL_RenderTexture(renderer_, t, nullptr, &dst);
};
drawBtn(btn_back_tex_, kW * 0.12f - kBtnW * 0.5f);
drawBtn(btn_skirmish_tex_, kW * 0.50f - kBtnW * 0.5f);
drawBtn(btn_fight_tex_, kW * 0.88f - kBtnW * 0.5f);
}
// ---------------------------------------------------------------------------
// Software-render the overlay surface each frame
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::DrawSurface(
WorkflowContext& context, uint32_t /*frameW*/, uint32_t /*frameH*/) {
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0);
SDL_RenderClear(renderer_);
SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND);
// ---- HUD (always visible unless menu is open) -----------------------
const bool menuOpen = context.GetBool("q3.menu_open", false);
if (!menuOpen) {
const auto bspCfg2 = context.Get<nlohmann::json>("bsp_config", nlohmann::json{});
const std::string pk3hud = bspCfg2.value("pk3_path", std::string(""));
if (!menu_tex_loaded_ && !pk3hud.empty()) TryLoadMenuTextures(pk3hud);
const int health = context.Get<int>("q3.player_health", 100);
const int armor = context.Get<int>("q3.player_armor", 0);
const int ammo = context.Get<int>("q3.player_ammo", 50);
// ── Large number HUD — bottom-left (matches Q3A layout) ────────────
// Scale 1.75 → each digit 56×56 px. Bottom edge at kH-6.
constexpr float kNS = 1.75f; // number sprite scale
constexpr float kNH = 32.f * kNS; // digit height = 56 px
constexpr float kHudY = kH - kNH - 6.f;
constexpr float kIS = 1.25f; // icon scale: 32×1.25 = 40 px
// Armor value (left)
float cx = 10.f;
cx = DrawHudNumber(cx, kHudY, armor, kNS);
// Armor icon immediately after digits
if (icon_armor_tex_) {
SDL_SetTextureAlphaMod(icon_armor_tex_, 255);
SDL_SetTextureColorMod(icon_armor_tex_, 255, 255, 255);
SDL_SetTextureBlendMode(icon_armor_tex_, SDL_BLENDMODE_BLEND);
SDL_FRect dst{cx + 4.f, kHudY + (kNH - 32.f*kIS)*0.5f, 32.f*kIS, 32.f*kIS};
SDL_RenderTexture(renderer_, icon_armor_tex_, nullptr, &dst);
}
cx += 4.f + 32.f*kIS + 14.f;
// Health value
cx = DrawHudNumber(cx, kHudY, health, kNS);
// Face icon after health digits
if (icon_face_tex_) {
SDL_SetTextureAlphaMod(icon_face_tex_, 255);
SDL_SetTextureColorMod(icon_face_tex_, 255, 255, 255);
SDL_SetTextureBlendMode(icon_face_tex_, SDL_BLENDMODE_BLEND);
SDL_FRect dst{cx + 4.f, kHudY, kNH, kNH}; // face is 64×64 → scaled to digit height
SDL_RenderTexture(renderer_, icon_face_tex_, nullptr, &dst);
}
// ── Ammo / weapon — bottom-right ────────────────────────────────────
// Show ammo icon + ammo count right-aligned at kW-10
const float ammoNumW = (ammo >= 100 ? 3 : ammo >= 10 ? 2 : 1) * 32.f * kNS;
const float ammoIconW = icon_ammo_tex_ ? 32.f * kIS : 0.f;
const float ammoX = kW - 10.f - ammoNumW - ammoIconW - 8.f;
if (icon_ammo_tex_) {
SDL_SetTextureAlphaMod(icon_ammo_tex_, 255);
SDL_SetTextureColorMod(icon_ammo_tex_, 255, 255, 255);
SDL_SetTextureBlendMode(icon_ammo_tex_, SDL_BLENDMODE_BLEND);
SDL_FRect dst{ammoX, kHudY + (kNH - 32.f*kIS)*0.5f, 32.f*kIS, 32.f*kIS};
SDL_RenderTexture(renderer_, icon_ammo_tex_, nullptr, &dst);
}
DrawHudNumber(ammoX + ammoIconW + 6.f, kHudY, ammo, kNS);
// ── Crosshair ────────────────────────────────────────────────────────
constexpr float kCHSize = 24.f;
const float chx = (kW - kCHSize) * 0.5f;
const float chy = (kH - kCHSize) * 0.5f;
if (icon_crosshair_) {
SDL_SetTextureAlphaMod(icon_crosshair_, 200);
SDL_SetTextureColorMod(icon_crosshair_, 255, 255, 255);
SDL_SetTextureBlendMode(icon_crosshair_, SDL_BLENDMODE_BLEND);
SDL_FRect dst{chx, chy, kCHSize, kCHSize};
SDL_RenderTexture(renderer_, icon_crosshair_, nullptr, &dst);
} else {
// Fallback dot crosshair
SDL_SetRenderDrawColor(renderer_, 255, 255, 255, 200);
SDL_FRect dot{(kW - 4.f) * 0.5f, (kH - 4.f) * 0.5f, 4.f, 4.f};
SDL_RenderFillRect(renderer_, &dot);
}
// ── Hit marker ───────────────────────────────────────────────────────
const uint32_t frame2 = static_cast<uint32_t>(context.GetDouble("loop.iteration", 0.0));
if (frame2 < context.Get<uint32_t>("q3.hit_marker_until_frame", 0u)) {
SDL_SetRenderDrawColor(renderer_, 255, 80, 55, 255);
SDL_RenderLine(renderer_, 308, 172, 320, 160);
SDL_RenderLine(renderer_, 332, 172, 320, 160);
SDL_RenderLine(renderer_, 308, 188, 320, 200);
SDL_RenderLine(renderer_, 332, 188, 320, 200);
}
// ── Muzzle flash ─────────────────────────────────────────────────────
const uint32_t frame3 = static_cast<uint32_t>(context.GetDouble("loop.iteration", 0.0));
if (frame3 < context.Get<uint32_t>("q3.weapon_flash_until_frame", 0u)) {
SDL_SetRenderDrawColor(renderer_, 255, 200, 60, 180);
SDL_FRect flash{kW - 40.f, kH * 0.58f, 24.f, 40.f};
SDL_RenderFillRect(renderer_, &flash);
}
}
// ---- Menu screens (full-screen overlays) --------------------------------
if (menuOpen) {
const auto bspCfg = context.Get<nlohmann::json>("bsp_config", nlohmann::json{});
const std::string pk3 = bspCfg.value("pk3_path", std::string(""));
if (!menu_tex_loaded_ && !pk3.empty()) TryLoadMenuTextures(pk3);
const std::string screen = context.Get<std::string>("q3.menu_screen", "main");
// Black background for all menu screens
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 255);
SDL_FRect bg{0, 0, static_cast<float>(kW), static_cast<float>(kH)};
SDL_RenderFillRect(renderer_, &bg);
if (screen == "map_select") {
DrawMapSelectScreen(context, pk3);
} else {
// ── Q3A main menu (ring + title + item list) ──────────────────
constexpr float kRingY = 120.f, kRingH = 185.f, kHalf = kW * 0.5f;
if (frame_l_tex_) {
SDL_SetTextureAlphaMod(frame_l_tex_, 255);
SDL_FRect d{0.f, kRingY, kHalf, kRingH};
SDL_RenderTexture(renderer_, frame_l_tex_, nullptr, &d);
}
if (frame_r_tex_) {
SDL_SetTextureAlphaMod(frame_r_tex_, 255);
SDL_FRect d{kHalf, kRingY, kHalf, kRingH};
SDL_RenderTexture(renderer_, frame_r_tex_, nullptr, &d);
}
constexpr float kCX = kW * 0.5f;
constexpr float kTY = 20.f, kTS = 1.5f, kSS = 1.1f;
const float kSY = kTY + kPropHeight * kTS + 2.f;
if (prop_glo_tex_) {
SDL_Texture* sv = prop_font_tex_; prop_font_tex_ = prop_glo_tex_;
DrawPropText(kCX+2.f, kTY+2.f, "QUAKE III", {255,80,0,120}, kTS, true);
DrawPropText(kCX+2.f, kSY+2.f, "ARENA", {255,80,0,120}, kSS, true);
prop_font_tex_ = sv;
}
DrawPropText(kCX, kTY, "QUAKE III", {255,200,50,255}, kTS, true);
DrawPropText(kCX, kSY, "ARENA", {220,100,10,255}, kSS, true);
const auto items = context.Get<nlohmann::json>("q3.menu_items", nlohmann::json::array());
const int sel = context.Get<int>("q3.menu_selected_item", 0);
const int nItems = static_cast<int>(items.size());
constexpr float kIS = 1.0f, kIStep = kPropHeight * kIS + 8.f, kITop = 185.f;
for (int i = 0; i < nItems; ++i) {
const std::string lbl = items[i].value("label", "");
const float iy = kITop + i * kIStep;
if (i == sel) {
if (prop_glo_tex_) {
SDL_Texture* sv = prop_font_tex_; prop_font_tex_ = prop_glo_tex_;
for (float ox : {-1.f, 1.f})
DrawPropText(kCX+ox, iy, lbl.c_str(), {255,180,0,100}, kIS, true);
prop_font_tex_ = sv;
}
DrawPropText(kCX, iy, lbl.c_str(), {255,220,80,255}, kIS, true);
} else {
DrawPropText(kCX, iy, lbl.c_str(), {200,55,35,210}, kIS, true);
}
}
DrawPropText(kCX, static_cast<float>(kH-18),
"Quake III Arena(c) 1999-2000, Id Software, Inc.",
{180,50,30,200}, 0.42f, true);
}
}
SDL_RenderPresent(renderer_);
// ---- Screenshot (CPU path: surface is already in RAM) --------------------
const auto* ssPath = context.TryGet<std::string>("screenshot_output_path");
if (ssPath && !ssPath->empty() && surface_) {
if (SDL_SaveBMP(surface_, ssPath->c_str()) == true) {
if (logger_) logger_->Info("q3.overlay: screenshot saved to " + *ssPath);
context.Set<bool>("screenshot_saved", true);
} else {
if (logger_) logger_->Warn("q3.overlay: SDL_SaveBMP failed for " + *ssPath);
context.Set<bool>("screenshot_saved", false);
}
// Clear the path so we only save once
context.Set<std::string>("screenshot_output_path", std::string(""));
}
}
// ---------------------------------------------------------------------------
// Upload surface → GPU texture → full-screen quad
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::Render(
SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchainTex,
SDL_GPUDevice* device, uint32_t /*frameW*/, uint32_t /*frameH*/) {
void* mapped = SDL_MapGPUTransferBuffer(device, transfer_, false);
if (!mapped) return;
std::memcpy(mapped, surface_->pixels, kW * kH * 4);
SDL_UnmapGPUTransferBuffer(device, transfer_);
auto* copy = SDL_BeginGPUCopyPass(cmd);
if (copy) {
SDL_GPUTextureTransferInfo src = {};
src.transfer_buffer = transfer_;
src.pixels_per_row = kW;
src.rows_per_layer = kH;
SDL_GPUTextureRegion dst = {};
dst.texture = tex_; dst.w = kW; dst.h = kH; dst.d = 1;
SDL_UploadToGPUTexture(copy, &src, &dst, false);
SDL_EndGPUCopyPass(copy);
}
if (!vbuf_uploaded_) {
const float verts[6][5] = {
{-1, 1,0,0,0},{1,1,0,1,0},{1,-1,0,1,1},
{-1,1,0,0,0},{1,-1,0,1,1},{-1,-1,0,0,1},
};
const uint32_t sz = sizeof(verts);
SDL_GPUTransferBufferCreateInfo tb = {};
tb.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tb.size = sz;
auto* tmp = SDL_CreateGPUTransferBuffer(device, &tb);
if (tmp) {
void* ptr = SDL_MapGPUTransferBuffer(device, tmp, false);
if (ptr) { std::memcpy(ptr, verts, sz); SDL_UnmapGPUTransferBuffer(device, tmp); }
auto* cp = SDL_BeginGPUCopyPass(cmd);
if (cp) {
SDL_GPUTransferBufferLocation s = {tmp, 0};
SDL_GPUBufferRegion d = {vtx_buf_, 0, sz};
SDL_UploadToGPUBuffer(cp, &s, &d, false);
SDL_EndGPUCopyPass(cp);
}
SDL_ReleaseGPUTransferBuffer(device, tmp);
}
vbuf_uploaded_ = true;
}
SDL_GPUColorTargetInfo target = {};
target.texture = swapchainTex;
target.load_op = SDL_GPU_LOADOP_LOAD;
target.store_op = SDL_GPU_STOREOP_STORE;
auto* pass = SDL_BeginGPURenderPass(cmd, &target, 1, nullptr);
if (!pass) return;
SDL_BindGPUGraphicsPipeline(pass, pipeline_);
SDL_GPUBufferBinding vb = {vtx_buf_, 0};
SDL_BindGPUVertexBuffers(pass, 0, &vb, 1);
SDL_GPUTextureSamplerBinding ts = {tex_, sampler_};
SDL_BindGPUFragmentSamplers(pass, 0, &ts, 1);
SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0);
SDL_EndGPURenderPass(pass);
}
// ---------------------------------------------------------------------------
void WorkflowQ3OverlayDrawStep::Execute(
const WorkflowStepDefinition& step, WorkflowContext& context) {
if (context.GetBool("frame_skip", false)) return;
auto* cmd = context.Get<SDL_GPUCommandBuffer*>("gpu_command_buffer", nullptr);
auto* swapchain = context.Get<SDL_GPUTexture*>("gpu_swapchain_texture", nullptr);
auto* device = context.Get<SDL_GPUDevice*>("gpu_device", nullptr);
if (!cmd || !swapchain || !device) return;
if (!ready_) TryInit(device, context.Get<SDL_Window*>("sdl_window", nullptr));
if (!ready_) return;
const auto fw = context.Get<uint32_t>("frame_width", 1280u);
const auto fh = context.Get<uint32_t>("frame_height", 960u);
DrawSurface(context, fw, fh);
Render(cmd, swapchain, device, fw, fh);
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,166 @@
#include "services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp"
#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp"
#include <nlohmann/json.hpp>
#include <SDL3/SDL.h>
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
using namespace q3overlay;
WorkflowOverlaySwBeginStep::WorkflowOverlaySwBeginStep(std::shared_ptr<ILogger> l)
: logger_(std::move(l)) {}
WorkflowOverlaySwBeginStep::~WorkflowOverlaySwBeginStep() {
if (renderer_) {
auto destroy = [&](SDL_Texture*& t) { if (t) { SDL_DestroyTexture(t); t=nullptr; } };
destroy(bigchars_); destroy(prop_); destroy(prop_glo_);
destroy(frame_l_); destroy(frame_r_);
for (auto& d : digits_) destroy(d);
destroy(icon_armor_); destroy(icon_health_); destroy(icon_face_); destroy(icon_weapon_);
destroy(crosshair_); destroy(btn_back_); destroy(btn_fight_); destroy(btn_skirmish_);
destroy(arrow_l_); destroy(arrow_r_);
// Levelshot textures owned by the cache
if (levelshot_cache_) {
for (auto& [k,v] : *levelshot_cache_) if (v) SDL_DestroyTexture(v);
}
SDL_DestroyRenderer(renderer_);
}
if (surface_) SDL_DestroySurface(surface_);
}
std::string WorkflowOverlaySwBeginStep::GetPluginId() const { return "overlay.sw.begin"; }
void WorkflowOverlaySwBeginStep::TryLoadTextures(const std::string& pk3) {
if (tex_loaded_) return;
tex_loaded_ = true;
auto load = [&](const char* e) { return LoadTextureFromPk3(renderer_, pk3, e); };
bigchars_ = load("gfx/2d/bigchars.tga");
prop_ = load("menu/art/font1_prop.tga");
prop_glo_ = load("menu/art/font1_prop_glo.tga");
frame_l_ = load("menu/art/frame1_l.tga");
frame_r_ = load("menu/art/frame1_r.tga");
static const char* kDigits[11] = {
"gfx/2d/numbers/zero_32b.tga", "gfx/2d/numbers/one_32b.tga",
"gfx/2d/numbers/two_32b.tga", "gfx/2d/numbers/three_32b.tga",
"gfx/2d/numbers/four_32b.tga", "gfx/2d/numbers/five_32b.tga",
"gfx/2d/numbers/six_32b.tga", "gfx/2d/numbers/seven_32b.tga",
"gfx/2d/numbers/eight_32b.tga", "gfx/2d/numbers/nine_32b.tga",
"gfx/2d/numbers/minus_32b.tga"
};
for (int i = 0; i < 11; ++i) digits_[i] = load(kDigits[i]);
icon_armor_ = load("icons/iconr_yellow.tga");
icon_health_ = load("icons/iconh_red.tga");
icon_face_ = load("models/players/keel/icon_default.tga");
icon_weapon_ = load("icons/iconw_machinegun.tga"); // weapon icon (right HUD), not ammo pickup
crosshair_ = load("gfx/2d/crosshaira.tga");
btn_back_ = load("menu/art/back_0.tga");
btn_fight_ = load("menu/art/fight_0.tga");
btn_skirmish_ = load("menu/art/skirmish_0.tga");
arrow_l_ = load("menu/art/gs_arrows_l.tga");
arrow_r_ = load("menu/art/gs_arrows_r.tga");
// Parse arenas.txt
arena_data_ = std::make_shared<ArenaMap>();
levelshot_cache_ = std::make_shared<LevelshotCache>();
int ze = 0;
zip_t* arc = zip_open(pk3.c_str(), ZIP_RDONLY, &ze);
if (arc) {
zip_stat_t st;
if (zip_stat(arc, "scripts/arenas.txt", 0, &st) == 0) {
std::vector<char> buf(st.size + 1, '\0');
zip_file_t* zf = zip_fopen(arc, "scripts/arenas.txt", 0);
if (zf) { zip_fread(zf, buf.data(), st.size); zip_fclose(zf); }
std::string text(buf.data());
size_t pos = 0;
while ((pos = text.find('{', pos)) != std::string::npos) {
size_t end = text.find('}', pos);
if (end == std::string::npos) break;
std::string block = text.substr(pos+1, end-pos-1);
pos = end + 1;
std::string mapName, longName, bots;
std::istringstream ss(block);
std::string tok;
while (ss >> tok) {
auto readVal = [&]() -> std::string {
std::string v; ss >> std::ws;
if (ss.peek()=='"') { ss.get(); std::getline(ss,v,'"'); }
else { ss >> v; }
return v;
};
if (tok=="map") mapName = readVal();
else if (tok=="longname") longName = readVal();
else if (tok=="bots") bots = readVal();
}
if (!mapName.empty()) {
std::string key = mapName;
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
std::string firstBot = bots.substr(0, bots.find(' '));
(*arena_data_)[key] = {longName, firstBot};
}
}
}
zip_close(arc);
}
if (logger_) logger_->Info("overlay.sw.begin: textures loaded from " + pk3);
}
void WorkflowOverlaySwBeginStep::Execute(
const WorkflowStepDefinition& /*step*/, WorkflowContext& context) {
if (context.GetBool("frame_skip", false)) return;
if (!ready_) {
surface_ = SDL_CreateSurface(kW, kH, SDL_PIXELFORMAT_RGBA32);
renderer_ = surface_ ? SDL_CreateSoftwareRenderer(surface_) : nullptr;
ready_ = surface_ && renderer_;
}
if (!ready_) return;
const auto bspCfg = context.Get<nlohmann::json>("bsp_config", nlohmann::json{});
const std::string pk3 = bspCfg.value("pk3_path", std::string(""));
if (!tex_loaded_ && !pk3.empty()) TryLoadTextures(pk3);
SDL_SetRenderDrawColor(renderer_, 0, 0, 0, 0);
SDL_RenderClear(renderer_);
SDL_SetRenderDrawBlendMode(renderer_, SDL_BLENDMODE_BLEND);
// Expose surface/renderer so drawing steps can use them
context.Set<SDL_Renderer*>("overlay.renderer", renderer_);
context.Set<SDL_Surface*> ("overlay.surface", surface_);
context.Set<bool> ("overlay.ready", ready_);
// Font textures
context.Set<SDL_Texture*>("overlay.tex.bigchars", bigchars_);
context.Set<SDL_Texture*>("overlay.tex.prop", prop_);
context.Set<SDL_Texture*>("overlay.tex.prop_glo", prop_glo_);
context.Set<SDL_Texture*>("overlay.tex.frame_l", frame_l_);
context.Set<SDL_Texture*>("overlay.tex.frame_r", frame_r_);
for (int i = 0; i < 11; ++i)
context.Set<SDL_Texture*>("overlay.tex.num." + std::to_string(i), digits_[i]);
// HUD icons
context.Set<SDL_Texture*>("overlay.tex.icon_armor", icon_armor_);
context.Set<SDL_Texture*>("overlay.tex.icon_health", icon_health_);
context.Set<SDL_Texture*>("overlay.tex.icon_face", icon_face_);
context.Set<SDL_Texture*>("overlay.tex.icon_weapon", icon_weapon_);
context.Set<SDL_Texture*>("overlay.tex.crosshair", crosshair_);
// Map-select UI
context.Set<SDL_Texture*>("overlay.tex.btn_back", btn_back_);
context.Set<SDL_Texture*>("overlay.tex.btn_fight", btn_fight_);
context.Set<SDL_Texture*>("overlay.tex.btn_skirmish", btn_skirmish_);
context.Set<SDL_Texture*>("overlay.tex.arrow_l", arrow_l_);
context.Set<SDL_Texture*>("overlay.tex.arrow_r", arrow_r_);
context.Set<std::shared_ptr<ArenaMap>> ("overlay.arena_data", arena_data_);
context.Set<std::shared_ptr<LevelshotCache>>("overlay.levelshot_cache", levelshot_cache_);
}
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,202 @@
#include "services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp"
#include "services/interfaces/workflow/workflow_step_parameter_resolver.hpp"
#include <SDL3/SDL.h>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
namespace sdl3cpp::services::impl {
namespace {
std::vector<uint8_t> LoadBin(const char* path) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f.is_open()) return {};
auto sz = f.tellg(); std::vector<uint8_t> d(sz);
f.seekg(0); f.read((char*)d.data(), sz); return d;
}
} // namespace
WorkflowOverlaySwEndStep::WorkflowOverlaySwEndStep(std::shared_ptr<ILogger> l)
: logger_(std::move(l)) {}
WorkflowOverlaySwEndStep::~WorkflowOverlaySwEndStep() {
if (device_) {
if (sampler_) SDL_ReleaseGPUSampler(device_, sampler_);
if (vtx_buf_) SDL_ReleaseGPUBuffer(device_, vtx_buf_);
if (transfer_) SDL_ReleaseGPUTransferBuffer(device_, transfer_);
if (tex_) SDL_ReleaseGPUTexture(device_, tex_);
if (pipeline_) SDL_ReleaseGPUGraphicsPipeline(device_, pipeline_);
}
}
std::string WorkflowOverlaySwEndStep::GetPluginId() const { return "overlay.sw.end"; }
void WorkflowOverlaySwEndStep::TryInit(
SDL_GPUDevice* device, SDL_Window* window,
const std::string& vertPath, const std::string& fragPath) {
device_ = device;
const char* driver = SDL_GetGPUDeviceDriver(device);
const std::string drv = driver ? driver : "";
SDL_GPUShaderFormat fmt = SDL_GPU_SHADERFORMAT_INVALID;
auto vert = LoadBin(vertPath.c_str());
auto frag = LoadBin(fragPath.c_str());
const char* entry = (drv == "metal") ? "main0" : "main";
if (drv == "metal") fmt = SDL_GPU_SHADERFORMAT_MSL;
else if (drv == "vulkan") fmt = SDL_GPU_SHADERFORMAT_SPIRV;
else { disabled_ = true; return; }
if (vert.empty() || frag.empty()) { disabled_ = true; return; }
SDL_GPUShaderCreateInfo vsi{}, fsi{};
vsi.code=vert.data(); vsi.code_size=vert.size(); vsi.entrypoint=entry;
vsi.format=fmt; vsi.stage=SDL_GPU_SHADERSTAGE_VERTEX;
fsi.code=frag.data(); fsi.code_size=frag.size(); fsi.entrypoint=entry;
fsi.format=fmt; fsi.stage=SDL_GPU_SHADERSTAGE_FRAGMENT; fsi.num_samplers=1;
auto* vs = SDL_CreateGPUShader(device, &vsi);
auto* fs = SDL_CreateGPUShader(device, &fsi);
if (!vs || !fs) {
if (vs) SDL_ReleaseGPUShader(device, vs);
if (fs) SDL_ReleaseGPUShader(device, fs);
disabled_ = true; return;
}
SDL_GPUVertexBufferDescription vbd{}; vbd.slot=0; vbd.pitch=sizeof(float)*5;
vbd.input_rate=SDL_GPU_VERTEXINPUTRATE_VERTEX;
SDL_GPUVertexAttribute attrs[2]{};
attrs[0]={0,0,SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3,0};
attrs[1]={1,0,SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2,sizeof(float)*3};
SDL_GPUVertexInputState vis{}; vis.vertex_buffer_descriptions=&vbd; vis.num_vertex_buffers=1;
vis.vertex_attributes=attrs; vis.num_vertex_attributes=2;
SDL_GPUColorTargetDescription ctd{};
ctd.format = window ? SDL_GetGPUSwapchainTextureFormat(device,window)
: SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM;
ctd.blend_state.enable_blend=true;
ctd.blend_state.src_color_blendfactor=SDL_GPU_BLENDFACTOR_SRC_ALPHA;
ctd.blend_state.dst_color_blendfactor=SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
ctd.blend_state.color_blend_op=SDL_GPU_BLENDOP_ADD;
ctd.blend_state.src_alpha_blendfactor=SDL_GPU_BLENDFACTOR_ONE;
ctd.blend_state.dst_alpha_blendfactor=SDL_GPU_BLENDFACTOR_ZERO;
ctd.blend_state.alpha_blend_op=SDL_GPU_BLENDOP_ADD;
SDL_GPUGraphicsPipelineCreateInfo pci{};
pci.vertex_shader=vs; pci.fragment_shader=fs; pci.vertex_input_state=vis;
pci.primitive_type=SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
pci.rasterizer_state.fill_mode=SDL_GPU_FILLMODE_FILL;
pci.rasterizer_state.cull_mode=SDL_GPU_CULLMODE_NONE;
pci.rasterizer_state.front_face=SDL_GPU_FRONTFACE_COUNTER_CLOCKWISE;
pci.depth_stencil_state.enable_depth_test=false;
pci.depth_stencil_state.enable_depth_write=false;
pci.target_info.num_color_targets=1;
pci.target_info.color_target_descriptions=&ctd;
pipeline_=SDL_CreateGPUGraphicsPipeline(device,&pci);
SDL_ReleaseGPUShader(device,vs); SDL_ReleaseGPUShader(device,fs);
if (!pipeline_) { disabled_=true; return; }
SDL_GPUTextureCreateInfo tci{}; tci.type=SDL_GPU_TEXTURETYPE_2D;
tci.format=SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM;
tci.width=kW; tci.height=kH; tci.layer_count_or_depth=1; tci.num_levels=1;
tci.usage=SDL_GPU_TEXTUREUSAGE_SAMPLER;
tex_=SDL_CreateGPUTexture(device,&tci);
SDL_GPUTransferBufferCreateInfo tbci{}; tbci.usage=SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD;
tbci.size=kW*kH*4; transfer_=SDL_CreateGPUTransferBuffer(device,&tbci);
SDL_GPUBufferCreateInfo bci{}; bci.usage=SDL_GPU_BUFFERUSAGE_VERTEX;
bci.size=6u*5u*(uint32_t)sizeof(float); vtx_buf_=SDL_CreateGPUBuffer(device,&bci);
SDL_GPUSamplerCreateInfo sci{}; sci.min_filter=SDL_GPU_FILTER_NEAREST;
sci.mag_filter=SDL_GPU_FILTER_NEAREST; sci.mipmap_mode=SDL_GPU_SAMPLERMIPMAPMODE_NEAREST;
sci.address_mode_u=SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
sci.address_mode_v=SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
sampler_=SDL_CreateGPUSampler(device,&sci);
ready_ = tex_ && transfer_ && vtx_buf_ && sampler_ && pipeline_;
}
void WorkflowOverlaySwEndStep::Execute(
const WorkflowStepDefinition& step, WorkflowContext& context) {
if (context.GetBool("frame_skip", false)) return;
if (!context.GetBool("overlay.ready", false)) return;
auto* renderer = context.Get<SDL_Renderer*>("overlay.renderer", nullptr);
auto* surface = context.Get<SDL_Surface*> ("overlay.surface", nullptr);
auto* cmd = context.Get<SDL_GPUCommandBuffer*>("gpu_command_buffer", nullptr);
auto* swapchain = context.Get<SDL_GPUTexture*>("gpu_swapchain_texture", nullptr);
auto* device = context.Get<SDL_GPUDevice*>("gpu_device", nullptr);
if (!surface || !cmd || !swapchain || !device) return;
if (renderer) SDL_RenderPresent(renderer);
if (!ready_ && !disabled_) {
WorkflowStepParameterResolver params;
auto getStr = [&](const char* k, const char* def) -> std::string {
const auto* p = params.FindParameter(step, k);
return (p && p->type == WorkflowParameterValue::Type::String)
? p->stringValue : def;
};
const char* driver = SDL_GetGPUDeviceDriver(device);
const std::string drv = driver ? driver : "";
std::string vp, fp;
if (drv == "metal") {
vp = getStr("vert_shader_path_msl",
"packages/quake3/shaders/msl/overlay.vert.metal");
fp = getStr("frag_shader_path_msl",
"packages/quake3/shaders/msl/overlay.frag.metal");
} else {
vp = getStr("vert_shader_path_spirv",
"packages/quake3/shaders/spirv/overlay.vert.spv");
fp = getStr("frag_shader_path_spirv",
"packages/quake3/shaders/spirv/overlay.frag.spv");
}
TryInit(device, context.Get<SDL_Window*>("sdl_window", nullptr), vp, fp);
}
if (!ready_) return;
// Upload surface pixels to GPU texture
void* mapped = SDL_MapGPUTransferBuffer(device, transfer_, false);
if (!mapped) return;
std::memcpy(mapped, surface->pixels, kW * kH * 4);
SDL_UnmapGPUTransferBuffer(device, transfer_);
auto* copy = SDL_BeginGPUCopyPass(cmd);
if (copy) {
SDL_GPUTextureTransferInfo src{}; src.transfer_buffer=transfer_;
src.pixels_per_row=kW; src.rows_per_layer=kH;
SDL_GPUTextureRegion dst{}; dst.texture=tex_; dst.w=kW; dst.h=kH; dst.d=1;
SDL_UploadToGPUTexture(copy, &src, &dst, false);
SDL_EndGPUCopyPass(copy);
}
// Upload vertex buffer once
if (!vbuf_uploaded_) {
const float verts[6][5]={{-1,1,0,0,0},{1,1,0,1,0},{1,-1,0,1,1},
{-1,1,0,0,0},{1,-1,0,1,1},{-1,-1,0,0,1}};
const uint32_t sz=sizeof(verts);
SDL_GPUTransferBufferCreateInfo tb{}; tb.usage=SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; tb.size=sz;
auto* tmp=SDL_CreateGPUTransferBuffer(device,&tb);
if (tmp) {
void* ptr=SDL_MapGPUTransferBuffer(device,tmp,false);
if (ptr) { std::memcpy(ptr,verts,sz); SDL_UnmapGPUTransferBuffer(device,tmp); }
auto* cp=SDL_BeginGPUCopyPass(cmd);
if (cp) {
SDL_GPUTransferBufferLocation s{tmp,0};
SDL_GPUBufferRegion d{vtx_buf_,0,sz};
SDL_UploadToGPUBuffer(cp,&s,&d,false);
SDL_EndGPUCopyPass(cp);
}
SDL_ReleaseGPUTransferBuffer(device,tmp);
}
vbuf_uploaded_=true;
}
// Render full-screen quad
SDL_GPUColorTargetInfo target{}; target.texture=swapchain;
target.load_op=SDL_GPU_LOADOP_LOAD; target.store_op=SDL_GPU_STOREOP_STORE;
auto* pass=SDL_BeginGPURenderPass(cmd,&target,1,nullptr);
if (!pass) return;
SDL_BindGPUGraphicsPipeline(pass,pipeline_);
SDL_GPUBufferBinding vb{vtx_buf_,0}; SDL_BindGPUVertexBuffers(pass,0,&vb,1);
SDL_GPUTextureSamplerBinding ts{tex_,sampler_}; SDL_BindGPUFragmentSamplers(pass,0,&ts,1);
SDL_DrawGPUPrimitives(pass,6,1,0,0);
SDL_EndGPURenderPass(pass);
// Screenshot
const auto* ssPath = context.TryGet<std::string>("screenshot_output_path");
if (ssPath && !ssPath->empty()) {
if (SDL_SaveBMP(surface, ssPath->c_str()))
context.Set<bool>("screenshot_saved", true);
context.Set<std::string>("screenshot_output_path", std::string(""));
}
}
} // namespace sdl3cpp::services::impl
@@ -4,7 +4,9 @@
#include <rapidjson/document.h>
#include <filesystem>
#include <stdexcept>
#include <unordered_set>
#include <utility>
namespace sdl3cpp::services::impl {
@@ -45,6 +47,15 @@ WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::pa
// Read workflow variables (n8n-style)
ParseVariables(document, workflow);
// Build include-cycle detection set (canonical path of THIS file)
std::unordered_set<std::string> visited;
try {
visited.insert(std::filesystem::canonical(path).string());
} catch (...) {
visited.insert(path.string());
}
const std::filesystem::path baseDir = path.parent_path();
// Handle "steps" format (simple sequential)
if (hasSteps) {
if (!document["steps"].IsArray()) {
@@ -62,12 +73,17 @@ WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::pa
step.parameters = paramReader.ReadParameterMap(entry, "parameters");
workflow.steps.push_back(std::move(step));
}
// Expand workflow.include nodes (parse-time composition, like React imports)
ResolveIncludes(workflow.steps, baseDir, visited);
return workflow;
}
// Handle "nodes" format (n8n with connections)
workflow.steps = ParseNodes(document);
// Expand workflow.include nodes (parse-time composition, like React imports)
ResolveIncludes(workflow.steps, baseDir, visited);
return workflow;
}
@@ -0,0 +1,106 @@
#include "services/interfaces/workflow/workflow_definition_parser.hpp"
#include "services/interfaces/workflow_step_definition.hpp"
#include "services/interfaces/workflow_parameter_value.hpp"
#include <filesystem>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <vector>
namespace fs = std::filesystem;
namespace sdl3cpp::services::impl {
// Resolve the include path:
// 1. Absolute path → use directly.
// 2. Relative path → try CWD first (game assets live under CWD/packages/…),
// then relative to the including file's directory.
static fs::path ResolvePath(const std::string& rawPath, const fs::path& baseDir) {
fs::path p(rawPath);
if (p.is_absolute()) return p;
// CWD-relative first (matches how shader/asset paths work in workflows)
const fs::path cwdCandidate = fs::current_path() / p;
if (fs::exists(cwdCandidate)) return cwdCandidate;
// Fallback: relative to the including file
const fs::path relCandidate = baseDir / p;
if (fs::exists(relCandidate)) return relCandidate;
// Return CWD-relative even if it doesn't exist yet — ParseFile will throw a
// better "file not found" message than we would.
return cwdCandidate;
}
void WorkflowDefinitionParser::ResolveIncludes(
std::vector<WorkflowStepDefinition>& steps,
const fs::path& baseDir,
std::unordered_set<std::string>& visited) const {
std::vector<WorkflowStepDefinition> expanded;
expanded.reserve(steps.size());
for (auto& step : steps) {
if (step.plugin != "workflow.include") {
expanded.push_back(std::move(step));
continue;
}
// Read the required "path" parameter
auto pathIt = step.parameters.find("path");
if (pathIt == step.parameters.end() ||
pathIt->second.type != WorkflowParameterValue::Type::String ||
pathIt->second.stringValue.empty()) {
if (logger_) {
logger_->Warn("workflow.include step '" + step.id +
"' is missing a 'path' parameter — skipping");
}
continue;
}
const fs::path includePath = ResolvePath(pathIt->second.stringValue, baseDir);
// Cycle detection
std::string canonicalKey;
try {
canonicalKey = fs::canonical(includePath).string();
} catch (...) {
canonicalKey = includePath.string();
}
if (visited.count(canonicalKey)) {
throw std::runtime_error(
"workflow.include cycle detected: '" + canonicalKey +
"' is already on the include stack");
}
if (logger_) {
logger_->Info("workflow.include: expanding '" + step.id +
"' → " + includePath.string());
}
// Parse the included file (its own ResolveIncludes runs inside ParseFile,
// so nested includes are handled automatically).
visited.insert(canonicalKey);
WorkflowDefinition sub = ParseFile(includePath);
visited.erase(canonicalKey);
// Namespace included node IDs to prevent collisions when the same
// sub-workflow is included more than once.
// e.g. include id "overlay_group" + sub id "q3_hud" → "overlay_group/q3_hud"
const std::string ns = step.id + "/";
for (auto& subStep : sub.steps) {
subStep.id = ns + subStep.id;
expanded.push_back(std::move(subStep));
}
if (logger_) {
logger_->Info("workflow.include: '" + step.id + "' expanded to " +
std::to_string(sub.steps.size()) + " steps");
}
}
steps = std::move(expanded);
}
} // namespace sdl3cpp::services::impl
@@ -116,7 +116,13 @@
// Quake 3
#include "services/interfaces/workflow/quake3/workflow_q3_menu_update_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_weapon_update_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_overlay_draw_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_hud_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_crosshair_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_hitmarker_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_menu_frame_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_mapselect_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_overlay_sw_begin_step.hpp"
#include "services/interfaces/workflow/rendering/workflow_overlay_sw_end_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_pickups_draw_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_md3_load_step.hpp"
#include "services/interfaces/workflow/quake3/workflow_q3_md3_draw_step.hpp"
@@ -331,13 +337,19 @@ void WorkflowRegistrar::RegisterSteps(std::shared_ptr<IWorkflowStepRegistry> reg
registry->RegisterStep(std::make_shared<WorkflowQ3MenuUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3WeaponUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3PickupsDrawStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3OverlayDrawStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowOverlaySwBeginStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3HudStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3CrosshairStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3HitmarkerStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MenuFrameStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3MapSelectStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowOverlaySwEndStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3Md3LoadStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3Md3DrawStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsSpawnStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsUpdateStep>(logger_));
registry->RegisterStep(std::make_shared<WorkflowQ3BotsDrawStep>(logger_));
count += 23;
count += 29;
// ── Texture ───────────────────────────────────────────────
registry->RegisterStep(std::make_shared<WorkflowTextureLoadStep>(logger_));
@@ -0,0 +1,142 @@
#pragma once
#include <SDL3/SDL_render.h>
#include <stb_image.h>
#include <zip.h>
#include <algorithm>
#include <string>
#include <unordered_map>
#include <vector>
namespace sdl3cpp::q3overlay {
struct ArenaInfo { std::string longname; std::string bot; };
using ArenaMap = std::unordered_map<std::string, ArenaInfo>;
using LevelshotCache = std::unordered_map<std::string, SDL_Texture*>;
static constexpr int kW = 640, kH = 360;
static constexpr int kGlyphSrc = 16;
static constexpr int kPropHeight = 27;
static constexpr int kPropGap = 3;
static constexpr int kPropSpace = 8;
// Q3 proportional font character map [128][3] = {src_x, src_y, width}
// From ioquake3 code/q3_ui/ui_atoms.c propMap
inline constexpr int kPropMap[128][3] = {
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},{0,0,-1},
{0,0,8},
{11,122,7},{154,181,14},{55,122,17},{79,122,18},{101,122,23},{153,122,18},{9,93,7},
{207,122,8},{230,122,9},{177,122,18},{30,152,18},{85,181,7},{34,93,11},{110,181,6},{130,152,14},
{22,64,17},{41,64,12},{58,64,17},{78,64,18},{98,64,19},{120,64,18},{141,64,18},{204,64,16},{162,64,17},{182,64,18},
{59,181,7},{35,181,7},{203,152,14},{56,93,14},{228,152,14},{177,181,18},{28,122,22},
{5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17},
{164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18},
{68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29},
{215,34,18},{234,34,18},{5,64,14},
{60,152,7},{106,151,13},{83,152,7},{128,122,17},{4,152,21},{134,181,5},
{5,4,18},{27,4,18},{48,4,18},{69,4,17},{90,4,13},{106,4,13},{121,4,18},{143,4,17},
{164,4,8},{175,4,16},{195,4,18},{216,4,12},{230,4,23},{6,34,18},{27,34,18},{48,34,18},
{68,34,18},{90,34,17},{110,34,18},{130,34,14},{146,34,18},{166,34,19},{185,34,29},
{215,34,18},{234,34,18},{5,64,14},
{153,152,13},{11,181,5},{180,152,13},{79,93,17},{0,0,-1}
};
inline SDL_Texture* LoadTextureFromPk3(
SDL_Renderer* renderer, const std::string& pk3Path, const char* entry) {
if (pk3Path.empty() || !renderer || !entry) return nullptr;
int ze = 0;
zip_t* arc = zip_open(pk3Path.c_str(), ZIP_RDONLY, &ze);
if (!arc) return nullptr;
zip_stat_t st;
if (zip_stat(arc, entry, 0, &st) != 0) { zip_close(arc); return nullptr; }
std::vector<uint8_t> buf(st.size);
zip_file_t* zf = zip_fopen(arc, entry, 0);
if (!zf) { zip_close(arc); return nullptr; }
zip_fread(zf, buf.data(), st.size);
zip_fclose(zf); zip_close(arc);
int w = 0, h = 0, ch = 0;
unsigned char* px = stbi_load_from_memory(buf.data(), (int)buf.size(), &w, &h, &ch, 4);
if (!px) return nullptr;
SDL_Surface* surf = SDL_CreateSurfaceFrom(w, h, SDL_PIXELFORMAT_RGBA32, px, w * 4);
SDL_Texture* tex = surf ? SDL_CreateTextureFromSurface(renderer, surf) : nullptr;
if (surf) SDL_DestroySurface(surf);
stbi_image_free(px);
return tex;
}
inline float PropStringWidth(const char* text) {
if (!text) return 0.f;
float w = 0.f;
for (const char* p = text; *p; ++p) {
int ch = (unsigned char)*p & 127;
int cw = kPropMap[ch][2];
if (cw < 0) continue;
w += (float)(cw == kPropSpace ? kPropSpace : cw + kPropGap);
}
return w > 0.f ? w - kPropGap : 0.f;
}
inline void DrawPropText(SDL_Renderer* r, SDL_Texture* font,
float x, float y, const char* text, SDL_Color col, float scale, bool center) {
if (!text || !r || !font) return;
if (center) x -= PropStringWidth(text) * scale * 0.5f;
SDL_SetTextureColorMod(font, col.r, col.g, col.b);
SDL_SetTextureAlphaMod(font, col.a);
SDL_SetTextureBlendMode(font, SDL_BLENDMODE_BLEND);
float cx = x;
for (const char* p = text; *p; ++p) {
int ch = (unsigned char)*p & 127;
int cw = kPropMap[ch][2];
if (cw < 0) continue;
if (cw == kPropSpace) { cx += kPropSpace * scale; continue; }
SDL_FRect src{(float)kPropMap[ch][0], (float)kPropMap[ch][1], (float)cw, (float)kPropHeight};
SDL_FRect dst{cx, y, cw * scale, kPropHeight * scale};
SDL_RenderTexture(r, font, &src, &dst);
cx += (cw + kPropGap) * scale;
}
}
inline void DrawQ3Text(SDL_Renderer* r, SDL_Texture* bigchars,
float x, float y, const char* text, SDL_Color col, float scale) {
if (!text || !r) return;
if (bigchars) {
SDL_SetTextureColorMod(bigchars, col.r, col.g, col.b);
SDL_SetTextureAlphaMod(bigchars, col.a);
SDL_SetTextureBlendMode(bigchars, SDL_BLENDMODE_BLEND);
float cx = x;
for (const char* p = text; *p; ++p) {
int code = (unsigned char)*p;
SDL_FRect src{(float)((code%16)*kGlyphSrc), (float)((code/16)*kGlyphSrc),
(float)kGlyphSrc, (float)kGlyphSrc};
SDL_FRect dst{cx, y, kGlyphSrc * scale, kGlyphSrc * scale};
SDL_RenderTexture(r, bigchars, &src, &dst);
cx += kGlyphSrc * scale;
}
} else {
SDL_SetRenderDrawColor(r, col.r, col.g, col.b, col.a);
SDL_RenderDebugText(r, x, y, text);
}
}
inline float DrawHudNumber(SDL_Renderer* r, SDL_Texture* digits[11],
float x, float y, int value, float scale) {
if (!r) return x;
std::string s = (value < 0) ? ("-" + std::to_string(-value)) : std::to_string(value);
const float dw = 32.f * scale;
for (char c : s) {
int idx = (c == '-') ? 10 : (c - '0');
if (idx >= 0 && idx <= 10 && digits[idx]) {
SDL_SetTextureColorMod(digits[idx], 255, 220, 60);
SDL_SetTextureAlphaMod(digits[idx], 255);
SDL_SetTextureBlendMode(digits[idx], SDL_BLENDMODE_BLEND);
SDL_FRect dst{x, y, dw, dw};
SDL_RenderTexture(r, digits[idx], nullptr, &dst);
}
x += dw;
}
return x;
}
} // namespace sdl3cpp::q3overlay
@@ -0,0 +1,16 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowQ3CrosshairStep final : public IWorkflowStep {
public:
explicit WorkflowQ3CrosshairStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,16 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowQ3HitmarkerStep final : public IWorkflowStep {
public:
explicit WorkflowQ3HitmarkerStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,16 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowQ3HudStep final : public IWorkflowStep {
public:
explicit WorkflowQ3HudStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,16 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowQ3MapSelectStep final : public IWorkflowStep {
public:
explicit WorkflowQ3MapSelectStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,16 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowQ3MenuFrameStep final : public IWorkflowStep {
public:
explicit WorkflowQ3MenuFrameStep(std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl
@@ -1,110 +0,0 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <SDL3/SDL_gpu.h>
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_surface.h>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
namespace sdl3cpp::services::impl {
class WorkflowQ3OverlayDrawStep final : public IWorkflowStep {
public:
explicit WorkflowQ3OverlayDrawStep(std::shared_ptr<ILogger> logger);
~WorkflowQ3OverlayDrawStep();
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
void TryInit(SDL_GPUDevice* device, SDL_Window* window);
void TryLoadMenuTextures(const std::string& pk3Path);
void ParseArenas(const std::string& pk3Path);
void DrawSurface(WorkflowContext& context, uint32_t frameW, uint32_t frameH);
void DrawMapSelectScreen(WorkflowContext& context, const std::string& pk3Path);
void Render(SDL_GPUCommandBuffer* cmd, SDL_GPUTexture* swapchainTex,
SDL_GPUDevice* device, uint32_t frameW, uint32_t frameH);
// Render a string using the Q3 bigchars grid font.
void DrawQ3Text(float x, float y, const char* text, SDL_Color color, float scale = 1.0f);
// Render a string using Q3's proportional font (font1_prop.tga).
// center=true → x is the horizontal centre of the string.
void DrawPropText(float x, float y, const char* text, SDL_Color color,
float scale = 1.0f, bool center = false);
// Draw an integer value using the gfx/2d/numbers/* sprites.
// Returns the x position just past the last digit.
float DrawHudNumber(float x, float y, int value, float scale);
// Compute pixel width of a proportional string at scale=1.
float PropStringWidth(const char* text) const;
SDL_Texture* LoadTextureFromPk3(const std::string& pk3Path, const char* entry);
SDL_Texture* LoadOrGetLevelshot(const std::string& mapName, const std::string& pk3Path);
std::shared_ptr<ILogger> logger_;
// GPU overlay pipeline state
bool ready_ = false;
bool disabled_ = false;
bool vbuf_uploaded_= false;
SDL_GPUDevice* device_ = nullptr;
SDL_GPUGraphicsPipeline* pipeline_ = nullptr;
SDL_GPUTexture* tex_ = nullptr;
SDL_GPUTransferBuffer* transfer_ = nullptr;
SDL_GPUBuffer* vtx_buf_ = nullptr;
SDL_GPUSampler* sampler_ = nullptr;
SDL_Surface* surface_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
// Menu / font textures loaded from PK3
bool menu_tex_loaded_ = false;
SDL_Texture* bigchars_tex_ = nullptr; // gfx/2d/bigchars.tga
SDL_Texture* prop_font_tex_ = nullptr; // menu/art/font1_prop.tga
SDL_Texture* prop_glo_tex_ = nullptr; // menu/art/font1_prop_glo.tga
SDL_Texture* frame_bg_tex_ = nullptr; // menu/art/cut_frame.tga
SDL_Texture* frame_l_tex_ = nullptr; // menu/art/frame1_l.tga (left ring half)
SDL_Texture* frame_r_tex_ = nullptr; // menu/art/frame1_r.tga (right ring half)
SDL_Texture* frame2_l_tex_ = nullptr; // menu/art/frame2_l.tga
// HUD number sprites: indices 0-9 = digits, index 10 = minus sign
SDL_Texture* num_digits_[11] = {};
// HUD icon textures
SDL_Texture* icon_armor_tex_ = nullptr; // icons/iconr_yellow.tga
SDL_Texture* icon_health_tex_ = nullptr; // icons/iconh_red.tga
SDL_Texture* icon_face_tex_ = nullptr; // models/players/keel/icon_default.tga
SDL_Texture* icon_ammo_tex_ = nullptr; // icons/icona_machinegun.tga
SDL_Texture* icon_crosshair_ = nullptr; // gfx/2d/crosshaira.tga
// Map-select UI textures
SDL_Texture* btn_back_tex_ = nullptr; // menu/art/back_0.tga
SDL_Texture* btn_fight_tex_ = nullptr; // menu/art/fight_0.tga
SDL_Texture* btn_skirmish_tex_ = nullptr; // menu/art/skirmish_0.tga
SDL_Texture* btn_arrow_l_tex_ = nullptr; // menu/art/gs_arrows_l.tga
SDL_Texture* btn_arrow_r_tex_ = nullptr; // menu/art/gs_arrows_r.tga
// Levelshot cache: uppercase map name → software SDL_Texture
std::unordered_map<std::string, SDL_Texture*> levelshot_cache_;
// Arena metadata parsed from scripts/arenas.txt
struct ArenaInfo { std::string longname; std::string bot; };
std::unordered_map<std::string, ArenaInfo> arena_data_; // key = lowercase map name
bool arena_data_loaded_ = false;
static constexpr int kW = 640;
static constexpr int kH = 360;
static constexpr int kGlyphSrc = 16; // bigchars cell size in source texture
static constexpr int kPropHeight = 27; // Q3 PROP_HEIGHT
static constexpr int kPropGap = 3; // Q3 PROP_GAP_WIDTH
static constexpr int kPropSpace = 8; // Q3 PROP_SPACE_WIDTH
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,44 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include "services/interfaces/workflow/quake3/q3_overlay_utils.hpp"
#include <SDL3/SDL_render.h>
#include <SDL3/SDL_surface.h>
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowOverlaySwBeginStep final : public IWorkflowStep {
public:
explicit WorkflowOverlaySwBeginStep(std::shared_ptr<ILogger> logger);
~WorkflowOverlaySwBeginStep();
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
void TryLoadTextures(const std::string& pk3Path);
std::shared_ptr<ILogger> logger_;
SDL_Surface* surface_ = nullptr;
SDL_Renderer* renderer_ = nullptr;
bool ready_ = false;
bool tex_loaded_ = false;
SDL_Texture* bigchars_ = nullptr;
SDL_Texture* prop_ = nullptr;
SDL_Texture* prop_glo_ = nullptr;
SDL_Texture* frame_l_ = nullptr;
SDL_Texture* frame_r_ = nullptr;
SDL_Texture* digits_[11]= {};
SDL_Texture* icon_armor_ = nullptr;
SDL_Texture* icon_health_ = nullptr;
SDL_Texture* icon_face_ = nullptr;
SDL_Texture* icon_weapon_ = nullptr; // iconw_machinegun — weapon icon on HUD right side
SDL_Texture* crosshair_ = nullptr;
SDL_Texture* btn_back_ = nullptr;
SDL_Texture* btn_fight_ = nullptr;
SDL_Texture* btn_skirmish_ = nullptr;
SDL_Texture* arrow_l_ = nullptr;
SDL_Texture* arrow_r_ = nullptr;
std::shared_ptr<q3overlay::ArenaMap> arena_data_;
std::shared_ptr<q3overlay::LevelshotCache> levelshot_cache_;
};
} // namespace sdl3cpp::services::impl
@@ -0,0 +1,31 @@
#pragma once
#include "services/interfaces/i_workflow_step.hpp"
#include "services/interfaces/i_logger.hpp"
#include "services/interfaces/workflow_context.hpp"
#include <SDL3/SDL_gpu.h>
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowOverlaySwEndStep final : public IWorkflowStep {
public:
explicit WorkflowOverlaySwEndStep(std::shared_ptr<ILogger> logger);
~WorkflowOverlaySwEndStep();
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
void TryInit(SDL_GPUDevice* device, SDL_Window* window,
const std::string& vertPath, const std::string& fragPath);
std::shared_ptr<ILogger> logger_;
bool ready_ = false;
bool disabled_ = false;
bool vbuf_uploaded_ = false;
SDL_GPUDevice* device_ = nullptr;
SDL_GPUGraphicsPipeline* pipeline_ = nullptr;
SDL_GPUTexture* tex_ = nullptr;
SDL_GPUTransferBuffer* transfer_ = nullptr;
SDL_GPUBuffer* vtx_buf_ = nullptr;
SDL_GPUSampler* sampler_ = nullptr;
static constexpr int kW = 640, kH = 360;
};
} // namespace sdl3cpp::services::impl
@@ -7,6 +7,8 @@
#include <filesystem>
#include <memory>
#include <unordered_set>
#include <vector>
namespace sdl3cpp::services::impl {
@@ -23,6 +25,13 @@ private:
std::vector<WorkflowStepDefinition> ParseNodes(
const rapidjson::Document& document) const;
// Expand any workflow.include steps in-place (parse-time composition).
// baseDir — directory of the including file, for relative path resolution.
// visited — canonical paths already on the include stack (cycle detection).
void ResolveIncludes(std::vector<WorkflowStepDefinition>& steps,
const std::filesystem::path& baseDir,
std::unordered_set<std::string>& visited) const;
std::shared_ptr<ILogger> logger_;
};