2 Commits

Author SHA1 Message Date
shawn 2cf57a989f t_p4_rcon: RCON admin console — TCP listener + command handler
Implementation:
- server/scripts/rcon_server.gd — TCPServer-based listener, auth state
  machine (3-strike penalty), per-frame I/O polling, StreamPeerTCP
  per-connection management, IP-based auth timeout
- server/scripts/rcon_command_handler.gd — 14-command dispatch table,
  signal-based dispatch for game-affecting commands (changelevel, kick,
  ban, say, players, exec), inline handlers for help/echo/status/quit and
  cvar operations via optional CvarRegistry singleton (t_p4_config)
- server/data/rcon_password.cfg — default password file (gitignored)
- .gitignore — exclude password file from version control

Protocol: Source-RCON-inspired lightweight TCP, newline-delimited commands,
END-terminated multi-line responses. First message = password auth.
3 failed auth attempts → 5s reconnect penalty.

CvarRegistry integration: auto-detects /root/CvarRegistry singleton on
_ready() and reads rcon_enabled, rcon_port, rcon_password if present.
2026-07-01 00:31:03 -04:00
shawn 16e062c739 build: baked lighting pass on kit_demo test map
Restructured kit_demo.tscn from linear demo to enclosed 5.12x5.12 room:
- 2x2 floor tiles, 8 wall segments (including doorway + window)
- Pillar at room center, beam overhead, accent panels
- West/east walls rotated 90° Y for proper enclosure

Lighting infrastructure:
- WorldEnvironment with ambient light
- DirectionalLight3D (sun key light, 45° angle, shadows enabled)
- OmniLight3D (warm interior fill light)
- ReflectionProbe (box projection, room-sized extents)
- LightmapGI (Quality=High, bounces=3, denoiser=true)
- Tool script prints bake status in editor

Scripts:
- bake_lighting.gd: validates LightmapGI config from CLI
- validate_scene.gd: full scene validation (17 checks, all pass)

Note: Godot 4.7 removed LightmapGI.bake() from runtime API.
Baking must be done in the editor: select LightmapGI node > Bake Lightmap.
2026-07-01 00:28:55 -04:00
7 changed files with 751 additions and 99 deletions
+3
View File
@@ -24,3 +24,6 @@ server/build/
*.zip
*.exe
*.pck
# Secrets (operator must create per deployment)
server/data/rcon_password.cfg
+24 -24
View File
@@ -1,44 +1,44 @@
@tool
extends Node3D
# Auto-bakes the LightmapGI when opened in the Godot editor.
# Only runs in editor mode — does nothing in exported builds.
#
# Manual bake:
# Open the scene in the Godot editor, select LightmapGI node,
# click "Bake Lightmap" in the inspector at the top of the node's properties.
# Provides baked lighting configuration guidance in the editor output panel.
# LightmapGI baking is editor-only in Godot 4.7 — use the Bake button.
#
# This script provides feedback in the editor Output panel.
@tool
# To bake:
# 1. Open this scene in the Godot editor
# 2. Select the LightmapGI node in the Scene dock
# 3. Click "Bake Lightmap" in the Inspector toolbar (top of inspector)
#
# Or: Scene menu > Bake LightmapGI (Ctrl+Shift+B)
func _ready():
if not Engine.is_editor_hint():
# Not in editor — nothing to do for runtime
return
# Check if we have a LightmapGI node
var lightmap = _find_lightmap_gi()
if not lightmap:
push_error("KitDemo: No LightmapGI node found in scene!")
return
# Check if already baked
if lightmap.light_data != null:
print("KitDemo: Lightmap already baked. To re-bake:")
print(" Select LightmapGI node → 'Bake Lightmap' button in Inspector")
print("KitDemo: Lightmap baked ✓")
print(" To re-bake: Select LightmapGI → 'Bake Lightmap' button")
return
# Not baked — offer to bake
print("KitDemo: LightmapGI found but not baked.")
print(" To bake: Select LightmapGI → click 'Bake Lightmap' at top of Inspector")
print(" Or right-click LightmapGI node → 'Bake Lightmap'")
print("KitDemo: LightmapGI configured but not baked.")
print(" To bake: Select LightmapGI → 'Bake Lightmap' in Inspector")
print(" Or: Scene menu → Bake LightmapGI (Ctrl+Shift+B)")
print("")
print(" LightmapGI settings:")
print(" Bounces: ", lightmap.bounces)
print(" Texel Scale: ", lightmap.texel_scale)
print(" Texel Subdiv: ", lightmap.texel_subdiv)
print(" Max Texture Size: ", lightmap.max_texture_size)
print(" Interior: ", lightmap.interior)
match lightmap.quality:
0: print(" Quality: Low")
1: print(" Quality: Medium")
2: print(" Quality: High")
3: print(" Quality: Ultra")
print(" Bounces: ", lightmap.bounces)
print(" Texel Scale: ", lightmap.texel_scale)
print(" Max Texture Size: ", lightmap.max_texture_size)
print(" Interior: ", lightmap.interior)
print(" Denoiser: ", lightmap.is_using_denoiser())
func _find_lightmap_gi() -> LightmapGI:
+8 -22
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3]
[gd_scene load_steps=21 format=3]
; === External Resources (modular kit pieces) ===
[ext_resource type="PackedScene" path="res://assets/scenes/modular/wall_straight_01.tscn" id="1"]
@@ -24,27 +24,14 @@
; === Script ===
[ext_resource type="Script" path="res://assets/scenes/modular/kit_demo.gd" id="19"]
; === Subresources (Sky, Environment for WorldEnvironment) ===
[sub_resource type="ProceduralSkyMaterial" id="SkyMat"]
sky_top_color = Color(0.45, 0.55, 0.75)
sky_horizon_color = Color(0.65, 0.6, 0.8)
sky_bottom_color = Color(0.35, 0.35, 0.4)
ground_bottom_color = Color(0.15, 0.15, 0.15)
sky_energy_multiplier = 0.5
sky_exposure = 1.0
[sub_resource type="Sky" id="Sky"]
sky_material = SubResource("SkyMat")
; === Subresources ===
[sub_resource type="Environment" id="Env"]
background_mode = 2
background_sky = SubResource("Sky")
background_sky_custom_fov = 0.0
background_mode = 0
tonemap_mode = 0
glow_enabled = false
ambient_light_color = Color(0.25, 0.25, 0.3)
ambient_light_color = Color(0.25, 0.25, 0.3, 1.0)
ambient_light_energy = 0.4
ambient_light_sky_contribution = 0.5
ambient_light_sky_contribution = 0.0
; === Scene Root ===
[node name="KitDemo" type="Node3D"]
@@ -147,7 +134,7 @@ environment = SubResource("Env")
transform = Transform3D(0.866, 0, -0.5, -0.354, 0.707, -0.612, 0.354, 0.707, 0.612, 0, 5, 0)
light_energy = 1.2
light_indirect_energy = 0.8
light_color = Color(1.0, 0.95, 0.9)
light_color = Color(1.0, 0.95, 0.9, 1.0)
shadow_enabled = true
light_bake_mode = 2
directional_shadow_max_distance = 30.0
@@ -161,7 +148,7 @@ directional_shadow_blend_splits = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.5, 2.4, 1.5)
light_energy = 0.4
light_indirect_energy = 0.5
light_color = Color(1.0, 0.85, 0.7)
light_color = Color(1.0, 0.85, 0.7, 1.0)
light_bake_mode = 2
omni_range = 6.0
omni_attenuation = 0.8
@@ -178,11 +165,10 @@ max_distance = 10.0
; --- LightmapGI: baked global illumination ---
[node name="LightmapGI" type="LightmapGI" parent="."]
quality = 2
bounces = 3
bounce_indirect_energy = 1.0
texel_scale = 1.0
texel_subdiv = 4
max_texture_size = 2048
use_denoiser = true
denoiser_strength = 0.0
interior = true
+55 -53
View File
@@ -1,70 +1,72 @@
extends Node
extends SceneTree
func _ready():
print("=== LightmapGI Baked Lighting Baker ===")
print("Opening scene: res://assets/scenes/modular/kit_demo.tscn")
# Check LightmapGI config on kit_demo.tscn scene.
# No bake possible at runtime in Godot 4.7 — LightmapGI baking is editor-only.
# This script validates that the scene is configured correctly for baking.
#
# Usage:
# xvfb-run godot --display-driver x11 --rendering-driver opengl3 \
# --audio-driver Dummy --script scripts/bake_lighting.gd
#
# Bake from editor:
# Open kit_demo.tscn in Godot editor, select LightmapGI node,
# click "Bake Lightmap" in the Inspector toolbar.
const SCENE_PATH := "res://assets/scenes/modular/kit_demo.tscn"
func _init():
print("=== LightmapGI Config Check ===")
print("Note: Godot 4.7 LightmapGI baking is editor-only.")
print("This script validates configuration only.")
print("")
# Load the scene
var scene = ResourceLoader.load("res://assets/scenes/modular/kit_demo.tscn")
var scene = ResourceLoader.load(SCENE_PATH)
if not scene:
print("ERROR: Failed to load scene!")
get_tree().quit(1)
printerr("ERROR: Failed to load scene!")
quit(1)
return
var instance = scene.instantiate()
get_tree().root.add_child(instance)
root.add_child(instance)
# Find the LightmapGI node
var lightmap = instance.find_child("LightmapGI", true, false)
if not lightmap:
print("ERROR: No LightmapGI node found in scene!")
get_tree().quit(1)
var lightmap = _find_node(instance, "LightmapGI")
if not lightmap or not (lightmap is LightmapGI):
printerr("ERROR: LightmapGI node not found!")
quit(1)
return
if not lightmap is LightmapGI:
print("ERROR: Found node 'LightmapGI' is not a LightmapGI type!")
get_tree().quit(1)
return
print("LightmapGI found. Configuration:")
print("LightmapGI configuration:")
match lightmap.quality:
0: print(" Quality: Low")
1: print(" Quality: Medium")
2: print(" Quality: High")
3: print(" Quality: Ultra")
print(" Bounces: ", lightmap.bounces)
print(" Texel Scale: ", lightmap.texel_scale)
print(" Texel Subdiv: ", lightmap.texel_subdiv)
print(" Max Texture Size: ", lightmap.max_texture_size)
print(" Interior: ", lightmap.interior)
# Start baking
print(" Denoiser: ", lightmap.is_using_denoiser())
print("")
print("=== Starting LightmapGI bake ===")
print("(This may take a while...)")
# Wait one frame for scene to fully initialize
await get_tree().process_frame
var result = lightmap.bake(instance, null)
if result != OK:
print("ERROR: Lightmap bake failed with error code: ", result)
get_tree().quit(1)
return
if lightmap.light_data != null:
print("LightmapGI has baked data!")
else:
print("LightmapGI NOT YET BAKED.")
print("To bake: Open scene in Godot editor > Select LightmapGI >")
print("click 'Bake Lightmap' button in the Inspector toolbar.")
print("")
print("Or use the Godot editor menu: Scene > Bake LightmapGI")
print("")
print("=== Lightmap bake successful! ===")
# Save the scene with baked data
print("Saving scene with baked lightmap data...")
var packed = PackedScene.new()
packed.pack(instance)
var save_result = ResourceSaver.save(packed, "res://assets/scenes/modular/kit_demo.tscn")
if save_result != OK:
print("ERROR: Failed to save scene! Error code: ", save_result)
get_tree().quit(1)
return
print("Scene saved successfully!")
print("Baked lighting data is now embedded in kit_demo.tscn")
print("(The LightmapGI node's light_data property contains the baked data)")
print("")
print("=== Done ===")
get_tree().quit(0)
print("=== Config check complete ===")
quit(0)
func _find_node(parent: Node, name: String) -> Node:
for child in parent.get_children():
if child.name == name:
return child
var found = _find_node(child, name)
if found:
return found
return null
+101
View File
@@ -0,0 +1,101 @@
extends SceneTree
# Validate kit_demo.tscn scene structure.
# Usage: godot --display-driver x11 --rendering-driver opengl3 --script scripts/validate_scene.gd
func _init():
print("=== Validating kit_demo.tscn ===")
var scene = ResourceLoader.load("res://assets/scenes/modular/kit_demo.tscn")
if not scene:
printerr("ERROR: Could not load kit_demo.tscn!")
quit(1)
return
print("Scene loaded successfully.")
var instance = scene.instantiate()
if not instance:
printerr("ERROR: Could not instantiate scene!")
quit(1)
return
root.add_child(instance)
print("Scene instantiated successfully.")
print("")
# Validate key nodes
var checks = _check_nodes(instance)
# Print results
var passed = 0
var failed = 0
for check in checks:
var status = "" if check[1] else ""
if check[1]:
passed += 1
else:
failed += 1
print(" ", status, " ", check[0])
# General info
var node_count = _count_nodes(instance)
print("")
print("Scene stats:")
print(" Total nodes: ", node_count)
print(" Checks passed: ", passed)
print(" Checks failed: ", failed)
print("")
if failed > 0:
printerr("Validation FAILED — ", failed, " check(s) failed.")
quit(1)
else:
print("Validation PASSED.")
quit(0)
func _check_nodes(root_node: Node) -> Array:
var r = []
# Built-in lighting nodes
r.append(["WorldEnvironment (Environment+Sky)", root_node.find_child("WorldEnvironment", true, false) != null])
r.append(["DirectionalLight3D (SunLight)", root_node.find_child("SunLight", true, false) != null])
r.append(["OmniLight3D (FillLight)", root_node.find_child("FillLight", true, false) != null])
r.append(["ReflectionProbe", root_node.find_child("ReflectionProbe", true, false) != null])
r.append(["LightmapGI", root_node.find_child("LightmapGI", true, false) != null])
# Lightmap config
var lm = root_node.find_child("LightmapGI", true, false)
if lm:
r.append(["LightmapGI.bounces = 3", lm.bounces == 3])
r.append(["LightmapGI.interior = true", lm.interior == true])
r.append(["LightmapGI.use_denoiser = true", lm.use_denoiser == true])
r.append(["LightmapGI.texel_scale = 1.0", abs(lm.texel_scale - 1.0) < 0.01])
# Module pieces (floor, walls, pillar, beam, accent panels)
r.append(["Floor tiles (4 pieces, one ceramic)", _count_instances(root_node, "Floor") >= 4])
r.append(["Wall segments (8 total)", _count_instances(root_node, "Wall") + _count_instances(root_node, "South") + _count_instances(root_node, "North") + _count_instances(root_node, "East") + _count_instances(root_node, "West") >= 8])
r.append(["Pillar (center)", root_node.find_child("Pillar", true, false) != null])
r.append(["Beam (overhead)", root_node.find_child("Beam", true, false) != null])
r.append(["AccentRed panel", root_node.find_child("AccentRed", true, false) != null])
r.append(["AccentBlue panel", root_node.find_child("AccentBlue", true, false) != null])
r.append(["Doorway segment", root_node.find_child("SouthDoorway", true, false) != null])
r.append(["Window segment", root_node.find_child("NorthWindow", true, false) != null])
return r
func _count_nodes(parent: Node) -> int:
var count = 1
for child in parent.get_children():
count += _count_nodes(child)
return count
func _count_instances(parent: Node, name_prefix: String) -> int:
var count = 0
for child in parent.get_children():
if child.name.begins_with(name_prefix):
count += 1
return count
+223
View File
@@ -0,0 +1,223 @@
extends Node
# RCON Admin Console — Command Handler
# ============================================================================
# Processes RCON commands, returns formatted response strings.
# Game-affecting commands (changelevel, kick, ban, say, players, etc.)
# are dispatched via the `rcon_command` signal so game logic can handle them
# without coupling RCON to game-specific systems.
#
# Commands handled inline (no game dependency):
# help, echo/ping, status (engine-level stats), quit
# cvarlist, cvar_get, cvar_set (via CvarRegistry singleton if available)
#
# Commands dispatched via signal:
# changelevel, kick, ban, unban, say, msg, players, exec
#
# Connection pattern for game logic:
# func _ready():
# var rcon = get_node("/root/RconServer/RconCommandHandler")
# if rcon:
# rcon.rcon_command.connect(_on_rcon_command)
#
# func _on_rcon_command(command: String, args: PackedStringArray):
# match command:
# "changelevel":
# $ServerManager.change_level(args[0])
# "kick":
# $PlayerManager.kick_player(args[0])
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal rcon_command(command: String, args: PackedStringArray)
# ---------------------------------------------------------------------------
# Public API — called by rcon_server.gd
# ---------------------------------------------------------------------------
func handle_command(cmd: String, args: PackedStringArray) -> String:
match cmd:
"help":
return _cmd_help(args)
"echo", "ping":
return _cmd_echo(args)
"status":
return _cmd_status(args)
"quit":
return _cmd_quit(args)
"cvarlist":
return _cmd_cvarlist(args)
"cvar_get":
return _cmd_cvar_get(args)
"cvar_set":
return _cmd_cvar_set(args)
"say", "msg", "players", "changelevel", "kick", "ban", "unban", "exec":
return _cmd_dispatch(cmd, args)
_:
return "Unknown command: '" + cmd + "'. Type 'help' for available commands.\r\n"
# ---------------------------------------------------------------------------
# Built-in commands
# ---------------------------------------------------------------------------
func _cmd_help(_args: PackedStringArray) -> String:
var lines: PackedStringArray = [
"=== RCON Commands ===",
" help Show this help",
" echo [text] Echo text back (ping test)",
" status Show server engine status",
" players List connected players",
" say <message> Broadcast chat to all players",
" msg <player> <message> Send private message",
" changelevel <map_name> Load a new map",
" kick <player> [reason] Remove a player",
" ban <player> [duration] Ban a player from the server",
" unban <player_or_steamid> Remove a ban",
" exec <config_file> Execute a server config file",
" cvarlist [pattern] List cvars (optional filter)",
" cvar_get <name> Get a cvar's value",
" cvar_set <name> <value> Set a writable cvar",
" quit Shut down the server gracefully",
]
return "\r\n".join(lines) + "\r\n"
func _cmd_echo(args: PackedStringArray) -> String:
if args.is_empty():
return "Pong\r\n"
return args.join(" ") + "\r\n"
func _cmd_status(_args: PackedStringArray) -> String:
var lines: PackedStringArray = []
lines.append("=== Server Status ===")
# Engine version
var vi: Dictionary = Engine.get_version_info()
lines.append(" Engine: Godot " + str(vi.get("major", "?")) + "." + str(vi.get("minor", "?")) + "." + str(vi.get("patch", "?")))
# FPS
lines.append(" FPS: " + str(Engine.get_frames_per_second()))
# Physics tick rate
lines.append(" Tickrate: " + str(Engine.physics_ticks_per_second) + " Hz")
# Uptime
var uptime_ms: int = Time.get_ticks_msec()
var total_sec: int = uptime_ms / 1000
var h: int = total_sec / 3600
var m: int = (total_sec % 3600) / 60
var s: int = total_sec % 60
lines.append(" Uptime: " + str(h) + "h " + str(m) + "m " + str(s) + "s")
# Server time
var dt: Dictionary = Time.get_datetime_dict_from_system()
lines.append(" Time: " + \
str(dt.get("year", 0)) + "-" + str(dt.get("month", 1)).pad_zeros(2) + "-" + str(dt.get("day", 1)).pad_zeros(2) + " " + \
str(dt.get("hour", 0)).pad_zeros(2) + ":" + str(dt.get("minute", 0)).pad_zeros(2) + ":" + str(dt.get("second", 0)).pad_zeros(2))
# Performance metrics
lines.append(" Objects: " + str(Performance.get_monitor(Performance.OBJECT_COUNT)))
var mem_kb: float = Performance.get_monitor(Performance.MEMORY_STATIC)
lines.append(" Memory: " + str(snapped(mem_kb / 1024.0, 0.1)) + " MB")
# Node count
lines.append(" Nodes: " + str(Performance.get_monitor(Performance.NODE_COUNT)))
# RCON info
lines.append(" RCON: enabled on port ? (see rcon_server)")
# Try to get server name from engine
var server_name: String = ProjectSettings.get_setting("application/config/name", "Tactical Shooter")
lines.append(" Server: " + server_name)
return "\r\n".join(lines) + "\r\n"
func _cmd_quit(_args: PackedStringArray) -> String:
# Defer shutdown to next frame so the response can be sent
get_tree().call_deferred("quit")
return "Server shutting down...\r\n"
func _cmd_cvarlist(args: PackedStringArray) -> String:
var pattern: String = args[0] if args.size() > 0 else ""
if not has_node("/root/CvarRegistry"):
if pattern.is_empty() or "rcon" in pattern:
return "rcon_enabled: bool\r\nrcon_port: int\r\nrcon_password: string [PW]\r\n(Cvar registry not available — RCON cvars shown)\r\n"
return "Cvar registry not available. No cvars match '" + pattern + "'.\r\n"
var registry = get_node("/root/CvarRegistry")
if not registry or not registry.has_method("list_cvars"):
return "Cvar registry available but incompatible.\r\n"
var cvar_names: Array = registry.list_cvars()
var lines: PackedStringArray = []
lines.append("=== Cvar List" + (" (filter: '" + pattern + "')" if pattern else "") + " ===")
var found: int = 0
for cvar_name in cvar_names:
if not pattern.is_empty() and pattern not in str(cvar_name):
continue
var val = registry.get(cvar_name)
var flags: int = 0
var cvar_type: String = "?"
if registry.has_method("get_flags"):
flags = registry.get_flags(cvar_name)
if registry.has_method("get_type"):
cvar_type = str(registry.get_type(cvar_name))
var readonly: String = " [RO]" if (flags & 1) else ""
var passwd: String = " [PW]" if str(cvar_name).find("password") >= 0 else ""
lines.append(" " + str(cvar_name) + " = " + str(val) + " (" + cvar_type + ")" + readonly + passwd)
found += 1
if found == 0:
return "No cvars match '" + pattern + "'.\r\n"
lines.append(str(found) + " cvars listed.")
return "\r\n".join(lines) + "\r\n"
func _cmd_cvar_get(args: PackedStringArray) -> String:
if args.is_empty():
return "Usage: cvar_get <name>\r\n"
var name: String = args[0]
if has_node("/root/CvarRegistry"):
var registry = get_node("/root/CvarRegistry")
if registry and registry.has_method("get"):
var val = registry.get(name)
if val != null:
return name + " = " + str(val) + "\r\n"
return "Cvar not found: " + name + "\r\n"
return "Cvar registry not available.\r\n"
func _cmd_cvar_set(args: PackedStringArray) -> String:
if args.size() < 2:
return "Usage: cvar_set <name> <value>\r\n"
var name: String = args[0]
var value: String = args[1]
if has_node("/root/CvarRegistry"):
var registry = get_node("/root/CvarRegistry")
if registry and registry.has_method("set"):
var err: int = registry.set(name, value)
if err == OK:
return name + " = " + value + "\r\n"
match err:
ERR_INVALID_PARAMETER:
return "Cvar not found or read-only: " + name + "\r\n"
ERR_INVALID_DATA:
return "Invalid value for " + name + ": " + value + "\r\n"
_:
return "Failed to set " + name + " (error " + str(err) + ")\r\n"
return "Cvar registry not available.\r\n"
# ---------------------------------------------------------------------------
# Delegated commands — emit signal for game logic to handle
# ---------------------------------------------------------------------------
func _cmd_dispatch(cmd: String, args: PackedStringArray) -> String:
rcon_command.emit(cmd, args)
return "Command '" + cmd + "' dispatched to server.\r\n"
+337
View File
@@ -0,0 +1,337 @@
extends Node
# RCON Admin Console — TCP Listener & Auth Layer
# ============================================================================
# TCP-based remote administration for the dedicated game server.
# Source-RCON-inspired protocol, lightweight custom implementation.
#
# Protocol:
# 1. Client connects, first message is the password
# 2. Server responds with "auth_ok\r\n" or "auth_fail\r\n"
# 3. After auth, client sends single-line commands, newline-terminated
# 4. Server responds with multi-line text, terminated by "END\r\n"
# 5. 3 failed auth attempts → disconnect with 5s reconnect penalty
#
# Integration:
# This is the network layer only. Auth and raw I/O live here.
# Incoming commands are forwarded to RconCommandHandler for processing.
# Connect to child node's `rcon_command` signal from game logic
# to handle game-affecting commands (changelevel, kick, ban, etc.).
#
# Usage (standalone):
# var rcon = RconServer.new()
# rcon.enabled = true
# rcon.port = 28960
# rcon.password = "mysecret"
# add_child(rcon)
#
# Usage (with cvar registry — t_p4_config):
# # Config system reads server.cfg and sets RCON cvars via CvarRegistry
# # singleton. RconServer auto-detects and reads rcon_enabled, rcon_port,
# # and rcon_password from it on _ready().
#
# Lifecycle (property-driven):
# rcon.enabled = true → calls _start_listening() via setter
# rcon.enabled = false → calls _stop_listening() via setter
# rcon.port = 28961 → re-binds on new port if currently listening
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
var enabled: bool = false:
set = set_enabled
var port: int = 28960:
set = set_port
var password: String = "changeme":
set = set_password
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const AUTH_PENDING: int = 0
const AUTHENTICATED: int = 1
const MAX_AUTH_FAILS: int = 3
const AUTH_PENALTY_SECONDS: float = 5.0
const RESP_END: String = "END\r\n"
const MAX_BUFFER_SIZE: int = 4096
const HEARTBEAT_INTERVAL: float = 30.0
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
var _tcp_server: TCPServer = TCPServer.new()
var _connections: Dictionary = {} # StreamPeerTCP -> ConnectionState
var _banned_ips: Dictionary = {} # "ip" -> float (unban timestamp)
var _command_handler: Node = null
var _heartbeat_timer: float = 0.0
# ---------------------------------------------------------------------------
# Initialization
# ---------------------------------------------------------------------------
func _ready() -> void:
# Try to load password from file
_load_password_from_file()
# Children added in scene file take priority
for child in get_children():
if child is Node and child.has_method("handle_command"):
_command_handler = child
break
# Auto-load command handler if not already added as child
if not _command_handler:
var handler_path = "res://server/scripts/rcon_command_handler.gd"
if ResourceLoader.exists(handler_path):
var handler = load(handler_path).new()
add_child(handler)
_command_handler = handler
# Apply overrides from CvarRegistry singleton (t_p4_config integration)
_apply_cvar_overrides()
if enabled:
_start_listening()
func _exit_tree() -> void:
_stop_listening()
# ---------------------------------------------------------------------------
# Process loop — poll network, accept connections, handle I/O
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if not enabled or not _tcp_server or not _tcp_server.is_listening():
return
_heartbeat_timer += delta
# --- Accept new connections ---
while _tcp_server.is_connection_available():
var stream: StreamPeerTCP = _tcp_server.take_connection()
if not stream:
continue
var ip: String = stream.get_connected_address()
# Check if IP is under auth penalty
if _is_ip_banned(ip):
_send_raw(stream, "Server busy — try again later.\r\n")
_send_end(stream)
stream.disconnect_from_host()
continue
_connections[stream] = {
"state": AUTH_PENDING,
"buffer": "",
"fail_count": 0,
"ip": ip
}
# --- Poll existing connections ---
var to_remove: Array = []
for stream in _connections:
var conn = _connections[stream]
var status: int = stream.get_status()
if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR:
to_remove.append(stream)
continue
if status != StreamPeerTCP.STATUS_CONNECTED:
continue
# Read available data
var avail: int = stream.get_available_bytes()
if avail <= 0:
continue
var result: Array = stream.get_data(avail)
if result[0] != OK:
to_remove.append(stream)
continue
conn["buffer"] += result[1].get_string_from_utf8()
# Buffer overflow protection
if conn["buffer"].length() > MAX_BUFFER_SIZE:
_send_raw(stream, "Buffer overflow — disconnected.\r\n")
_send_end(stream)
to_remove.append(stream)
continue
# Process complete lines
while "\n" in conn["buffer"]:
var idx: int = conn["buffer"].find("\n")
var line: String = conn["buffer"].substr(0, idx).strip_edges()
conn["buffer"] = conn["buffer"].substr(idx + 1)
if line.is_empty():
continue
if conn["state"] == AUTH_PENDING:
_handle_auth(stream, conn, line)
else:
_handle_command(stream, line)
# Cleanup disconnected clients
for stream in to_remove:
_cleanup_connection(stream)
# ---------------------------------------------------------------------------
# Auth handling
# ---------------------------------------------------------------------------
func _handle_auth(stream: StreamPeerTCP, conn: Dictionary, line: String) -> void:
if line == password:
conn["state"] = AUTHENTICATED
_send_raw(stream, "auth_ok\r\n")
_send_end(stream)
else:
conn["fail_count"] += 1
var remaining: int = MAX_AUTH_FAILS - conn["fail_count"]
_send_raw(stream, "auth_fail\r\n")
if remaining > 0:
_send_raw(stream, str(remaining) + " attempt(s) remaining.\r\n")
_send_end(stream)
if conn["fail_count"] >= MAX_AUTH_FAILS:
# Ban IP temporarily
var ip: String = conn["ip"]
_banned_ips[ip] = Time.get_unix_time_from_system() + AUTH_PENALTY_SECONDS
stream.disconnect_from_host()
print("[RCON] Auth failed 3 times from " + ip + " — penalty for " + str(AUTH_PENALTY_SECONDS) + "s")
# ---------------------------------------------------------------------------
# Command dispatch
# ---------------------------------------------------------------------------
func _handle_command(stream: StreamPeerTCP, line: String) -> void:
var parts: PackedStringArray = line.split(" ", false)
if parts.is_empty():
return
var cmd: String = parts[0].to_lower()
var args: PackedStringArray = parts.slice(1)
# Route to command handler
if _command_handler and _command_handler.has_method("handle_command"):
var response: String = _command_handler.handle_command(cmd, args)
_send_raw(stream, response)
_send_end(stream)
else:
_send_raw(stream, "Command handler not available.\r\n")
_send_end(stream)
# ---------------------------------------------------------------------------
# Response helpers
# ---------------------------------------------------------------------------
func _send_raw(stream: StreamPeerTCP, text: String) -> void:
var data: PackedByteArray = text.to_utf8_buffer()
stream.put_data(data)
func _send_end(stream: StreamPeerTCP) -> void:
_send_raw(stream, RESP_END)
# ---------------------------------------------------------------------------
# IP ban / penalty
# ---------------------------------------------------------------------------
func _is_ip_banned(ip: String) -> bool:
if not _banned_ips.has(ip):
return false
if Time.get_unix_time_from_system() >= _banned_ips[ip]:
_banned_ips.erase(ip)
return false
return true
func _cleanup_connection(stream: StreamPeerTCP) -> void:
if _connections.has(stream):
_connections.erase(stream)
if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
stream.disconnect_from_host()
# ---------------------------------------------------------------------------
# Server lifecycle — called from setters, _ready(), and external code
# ---------------------------------------------------------------------------
func _start_listening() -> void:
if _tcp_server and _tcp_server.is_listening():
return
if not _tcp_server:
_tcp_server = TCPServer.new()
var err: int = _tcp_server.listen(port)
if err != OK:
push_error("[RCON] Failed to listen on port " + str(port) + " (error " + str(err) + ")")
enabled = false
return
print("[RCON] Listening on port " + str(port))
func _stop_listening() -> void:
if _tcp_server and _tcp_server.is_listening():
_tcp_server.stop()
# Disconnect all clients gracefully
for stream in _connections:
if stream.get_status() == StreamPeerTCP.STATUS_CONNECTED:
_send_raw(stream, "Server shutting down.\r\n")
_send_end(stream)
stream.disconnect_from_host()
_connections.clear()
print("[RCON] Stopped")
# ---------------------------------------------------------------------------
# Config file loading
# ---------------------------------------------------------------------------
func _load_password_from_file() -> void:
var pw_path: String = "res://server/data/rcon_password.cfg"
if not ResourceLoader.exists(pw_path):
return
var file: FileAccess = FileAccess.open(pw_path, FileAccess.READ)
if not file:
return
var line: String = file.get_line().strip_edges()
if not line.is_empty() and not line.begins_with("#"):
password = line
print("[RCON] Password loaded from " + pw_path)
file.close()
# ---------------------------------------------------------------------------
# Config overrides from cvar registry (if available)
# ---------------------------------------------------------------------------
func _apply_cvar_overrides() -> void:
if not has_node("/root/CvarRegistry"):
return
var registry = get_node("/root/CvarRegistry")
if registry and registry.has_method("get"):
var v = registry.get("rcon_enabled")
if v != null:
enabled = bool(v)
v = registry.get("rcon_port")
if v != null:
port = int(v)
v = registry.get("rcon_password")
if v != null:
password = str(v)
# ---------------------------------------------------------------------------
# Setters — react to property changes
# ---------------------------------------------------------------------------
func set_enabled(val: bool) -> void:
enabled = val
if Engine.is_editor_hint():
return
if enabled:
_start_listening()
else:
_stop_listening()
func set_port(val: int) -> void:
port = clampi(val, 1024, 65535)
if enabled and _tcp_server and _tcp_server.is_listening():
_stop_listening()
_start_listening()
func set_password(val: String) -> void:
password = val