Skip to content

Add Linux x86-64 support for HL2DM#533

Open
speedvoltage wants to merge 2 commits into
Source-Python-Dev-Team:masterfrom
speedvoltage:linux-x86_64
Open

Add Linux x86-64 support for HL2DM#533
speedvoltage wants to merge 2 commits into
Source-Python-Dev-Team:masterfrom
speedvoltage:linux-x86_64

Conversation

@speedvoltage

@speedvoltage speedvoltage commented Jul 16, 2026

Copy link
Copy Markdown

This PR aims at providing 64-bit support for Source.Python. I started this 6 months ago for fun, but mainly because I am working on a custom SSDK for HL2DM. I tested this on Ubuntu, and Half-Life 2: Deathmatch only. Anyone who would like to continue on from this should hopefully have an easier time.

SP plugins load in-game, including detouring functions. I tested chat hooks, HUD text, timers, and a bunch of other stuff.

I wanted to help some folks who sadly could not get EventScript working since it was not open source.

I am not too sure I can keep working on this due to other high importance game projects, but like I said, this should hopefully set up base for anyone else who wants to keep updating this.

Anyways, some output from a 64-bit HL2MP server:

user@user:~/Downloads/hl2dm-serverfiles$ ./start_hl2dm.sh
Auto detecting CPU
Using default binary: ./srcds_linux64
Server will auto-restart if there is a crash.
Using shader api: shaderapiempty_srv.so
Using Breakpad minidump system. Version: 9540945 AppID: 232370
Setting breakpad minidump AppID = 232370
Using breakpad crash handler
Loaded 11 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2mp/hl2mp_pak.vpk for pure server operation.
Loaded 11 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2mp/hl2mp_pak.vpk for pure server operation.
Loaded 1 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2_complete/hl2_complete_misc.vpk for pure server operation.
Loaded 1231 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2/hl2_textures.vpk for pure server operation.
Loaded 574 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2/hl2_sound_vo_english.vpk for pure server operation.
Loaded 383 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2/hl2_sound_misc.vpk for pure server operation.
Loaded 462 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/hl2/hl2_misc.vpk for pure server operation.
Loaded 5 VPK file hashes from /home/user/Downloads/hl2dm-serverfiles/platform/platform_misc.vpk for pure server operation.
server_srv.so loaded for "Half-Life 2 Deathmatch"
Parent cvar in server.dll not allowed (hl2mp_bot_debug_ammo_scavenging)
[Source.Python] Loading...
[Source.Python] Loaded successfully.
maxplayers set to 16
Looking up breakpad interfaces from steamclient
Calling BreakpadMiniDumpSystemInit
ConVarRef dev_loadtime_map_start doesn't point to an existing ConVar
Socket bound to non-default port 27018 because original port was already in use.
WARNING: Port 27017 was unavailable - bound to port 27018 instead
Socket bound to non-default port 27007 because original port was already in use.
WARNING: Port 27005 was unavailable - bound to port 27007 instead
Socket bound to non-default port 27022 because original port was already in use.
WARNING: Port 27020 was unavailable - bound to port 27022 instead
Network: IP 127.0.1.1, mode MP, dedicated Yes, ports 27018 SV / 27007 CL
Initializing Steam libraries for secure Internet server
[S_API] SteamAPI_Init(): Loaded local 'steamclient.so' OK.
Setting breakpad minidump AppID = 320
Looking up breakpad interfaces from steamclient
Calling BreakpadMiniDumpSystemInit
[S_API FAIL] Tried to access Steam interface SteamUtils010 before SteamAPI_Init succeeded.
Setting breakpad minidump AppID = 232370
No account token specified; logging into anonymous game server account.  (Use sv_setsteamaccount to login to a persistent account.)
---- Host_NewGame ----
Spawn Server: dm_lockdown
TODO: Refusing to discard 0 bytes
Legacy game DLL may not support terrain vphysics collisions with this BSP!
ConVarRef room_type doesn't point to an existing ConVar
execing skill_manifest.cfg
execing skill.cfg
Unknown command "sk_plr_dmg_crowbar"
Unknown command "sk_npc_dmg_crowbar"
Unknown command "sk_plr_dmg_stunstick"
Unknown command "sk_npc_dmg_stunstick"
Unknown command "sk_npc_dmg_strider_to_plr"
Unknown command "sk_npc_dmg_strider"
Unknown command "sk_max_strider"
Executing dedicated server config file server.cfg
Using map cycle file 'cfg/mapcycle_default.txt'.  ('cfg/mapcycle.txt' was not found.)
Set Gravity 600.0 (0.250 tolerance)
Error loading cfg/vscript_convar_allowlist.txt
VSCRIPT: Started VScript virtual machine using script language 'Squirrel'
VSCRIPT: Started VScript virtual machine using script language 'Squirrel'
Script not found (scripts/vscripts/mapspawn.nut) 
Script not found (scripts/vscripts/mapspawn.nut) 
Loading AI graph
Step 1 loading
Checking version
AI node graph maps/graphs/dm_lockdown.ain is out of date
props_lab/monitor02.mdl : material "models/props_lab/monitor_lost" not found.
Set motd from file 'cfg/motd_default.txt'.  ('cfg/motd.txt' was not found.)
Set motd_text from file 'cfg/motd_text_default.txt'.  ('cfg/motd_text.txt' was not found.)
SV_ActivateServer: setting tickrate to 66.7
Created class baseline: 29 classes, 7975 bytes.
16 player server started
execing server.cfg
Can't use cheat cvar hl2mp_bot_ignore_real_players in multiplayer, unless the server has sv_cheats set to 1.
'banned_ip.cfg' not present; not executing.
execing banned_user.cfg
Server logging enabled.
Server logging data to file logs/L0716009.log
L 07/16/2026 - 12:41:17: Log file started (file "logs/L0716009.log") (game "/home/user/Downloads/hl2dm-serverfiles/hl2mp") (version "9540945")
L 07/16/2026 - 12:41:17: server_cvar: "sv_report_client_settings" "1"
L 07/16/2026 - 12:41:17: server_cvar: "decalfrequency" "120"
L 07/16/2026 - 12:41:17: server_cvar: "mp_flashlight" "1"
L 07/16/2026 - 12:41:17: server_cvar: "mp_forcerespawn" "1"
L 07/16/2026 - 12:41:17: server_cvar: "mp_teamlist" "blue,red"
L 07/16/2026 - 12:41:17: server_cvar: "mp_timelimit" "15"
Unknown command "sv_anticheat"
L 07/16/2026 - 12:41:17: server_cvar: "sv_alltalk" "1"
L 07/16/2026 - 12:41:17: server_cvar: "sv_tags" "alltalk"
L 07/16/2026 - 12:41:17: server_cvar: "sv_tags" "alltalk"
L 07/16/2026 - 12:41:17: server_cvar: "sv_cheats" "1"
--------------------------------------------------------
sv_pure set to 1.
Note: Waiting for the next changelevel to apply the current value.
--------------------------------------------------------
Unknown command "rate"
maxplayers set to 17 (extra slot was added for SourceTV)
L 07/16/2026 - 12:41:17: server_cvar: "tv_enable" "1"
L 07/16/2026 - 12:41:17: server_cvar: "sv_tags" "alltalk,increased_maxplayers"
L 07/16/2026 - 12:41:17: server_cvar: "sv_tags" "alltalk,increased_maxplayers"
Unknown command "websocket_address"
Unknown command "websocket_secret"
Unknown command "websocket_motd_webpage"
Unknown command "motd_server_id"
Unknown command "motd_server_name"
Unknown command "sv_autorecord"
Unknown command "sv_demos_folder"
Unknown command "sv_demos_url"
Unknown command "sv_demo_upload_token"
Unknown command "sv_demo_upload_queue_dir"
Unknown command "sv_demo_upload_helper"
Server is hibernating
Connection to Steam servers successful.
   Public IP to Steam is 82.64.109.144.
Assigned anonymous gameserver Steam ID [A:1:3531693086:50540].
VAC secure mode is activated.
sp
[Source.Python] A sub-command is required:
  sp auth <sub-command>                     Authorization specific commands.
  sp credits                                List all credits for Source.Python.
  sp delay <delay:float> <command> [*args]  Execute a command after a given delay.
  sp docs <sub-command>                     Documentation specific commands.
  sp dump <sub-command>                     Dump various data to files.
  sp help [command=None]                    Print all sp sub-commands or help for a
    [*server_sub_commands]                    specific command.
  sp info                                   Print information about OS, SP and
                                              installed plugins.
  sp plugin <sub-command>                   Plugin specific commands.
  sp update                                 Update Source.Python to the latest
                                              version. A restart of the server is
                                              required.
sp info
[Source.Python] 
IMPORTANT: Please copy the full output.
--------------------------------------------------------
Checksum      : 57004a17405622d8ce94820aef4c8e41
Date          : 2026-07-16 10:41:29.428515+00:00
OS            : Linux-6.17.0-35-generic-x86_64-with-glibc2.39
Game          : hl2dm
Dedicated     : True
SP version    : None
Github commit : None
Server plugins:
   00: Source.Python, (C) 2012-2025, Source.Python Team.
SP plugins:
--------------------------------------------------------

Plugin validations:

[SP] Loaded Plugins: 1
-------------------------------------------------------------

sp_full_validation (Sp Full Validation):
   public convar:   sp_full_validation_version

-------------------------------------------------------------

Plugin behavior:

[SP] Loading plugin 'sp_full_validation'...
[sp_full_validation] registered on architecture x86_64
[sp_full_validation] runtime self-test PASS pointer_bits=64 sha256=174677f0bd6b
[sp_full_validation] loaded on architecture x86_64; interval=10.0 seconds
[SP] Successfully loaded plugin 'sp_full_validation'.
sp_full_runtime
[sp_full_validation] runtime self-test PASS pointer_bits=64 sha256=174677f0bd6b
[sp_full_validation] timer message burst skipped: no human players
sp_full_precache
[sp_full_validation] model data loaded: type=STUDIOHDR handle=304
[sp_full_validation] model data loaded: type=VCOLLIDE handle=304
[sp_full_validation] model data loaded: type=STUDIOHWDATA handle=304
[sp_full_validation] precache model=models/props_junk/watermelon01.mdl index=310 data_loaded_delta=3
sp_full_burst
[sp_full_validation] server-command message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
sp_full_status
[sp_full_validation] arch=x86_64 map=dm_lockdown humans=0 entities=446 pulse=True recolor=True errors=0
[sp_full_validation] hooks raw=0/0 balance=0 output=0 player=0/0 balance=0 hibernation=0 last_hibernation=None
[sp_full_validation] messages bursts=0 say=0 hint=0 hud=0 timer_callbacks=2 chat_seen=0 recolored=0 say_commands=0 client_commands=0
[sp_full_validation] lifecycle active=0 disconnect=0 level_init=0 level_shutdown=0 entity_create=0 entity_delete=0 spawn=0 death=0
[sp_full_validation] model load=3 unload=0 precache_runs=1 runtime_runs=2 last_output=None last_event=None last_runtime=PASS pointer_bits=64 sha256=174677f0bd6b last_error=None
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
L 07/16/2026 - 13:01:26: "Peter Brev<2><[U:1:400275221]><>" connected, address "192.168.1.33:27006"
Client "Peter Brev" connected (192.168.1.33:27006).
Server waking up from hibernation
[sp_full_validation] hibernation transition: False
[sp_full_validation] entity output=OnNewGame delay=0.000
[sp_full_validation] entity output=OnMapSpawn delay=0.000
[sp_full_validation] entity output=OnMultiNewMap delay=0.000
[sp_full_validation] entity output=OnNewGame delay=0.000
[sp_full_validation] entity output=OnMapSpawn delay=0.000
[sp_full_validation] entity output=OnMultiNewMap delay=0.000
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] entity output=OnPlay delay=0.000
sp_full_status
[sp_full_validation] arch=x86_64 map=dm_lockdown humans=1 entities=453 pulse=True recolor=True errors=0
[sp_full_validation] hooks raw=22/22 balance=0 output=22 player=3943/3943 balance=0 hibernation=1 last_hibernation=False
[sp_full_validation] messages bursts=7 say=7 hint=7 hud=7 timer_callbacks=13 chat_seen=2 recolored=0 say_commands=1 client_commands=1
[sp_full_validation] lifecycle active=1 disconnect=0 level_init=0 level_shutdown=0 entity_create=11 entity_delete=3 spawn=1 death=0
[sp_full_validation] model load=3 unload=0 precache_runs=1 runtime_runs=2 last_output=OnPlay last_event=player_spawn last_runtime=PASS pointer_bits=64 sha256=174677f0bd6b last_error=None
L 07/16/2026 - 13:02:35: "Peter Brev<2><[U:1:400275221]><Unassigned>" disconnected (reason "Client Disconnect")
Dropped Peter Brev from server (Client Disconnect)
Server is hibernating
[sp_full_validation] hibernation transition: True
[sp_full_validation] timer message burst skipped: no human players
sp plugin reload sp_full_validation
[SP] Unloading plugin 'sp_full_validation'...
[sp_full_validation] unloaded cleanly; errors=0 raw=22/22 player=4514/4514
[SP] Successfully unloaded plugin 'sp_full_validation'.
[SP] Loading plugin 'sp_full_validation'...
[sp_full_validation] registered on architecture x86_64
[sp_full_validation] runtime self-test PASS pointer_bits=64 sha256=174677f0bd6b
[sp_full_validation] loaded on architecture x86_64; interval=10.0 seconds
sp_full_precache
[sp_full_validation] precache model=models/props_junk/watermelon01.mdl index=310 data_loaded_delta=0
[sp_full_validation] entity output=OnBreak delay=0.000
[sp_full_validation] entity output=OnBreak delay=0.000

Behavior after level change:

changelevel dm_lockdown
---- Host_Changelevel ----
[sp_full_validation] entity output=OnBreak delay=0.000
[sp_full_validation] entity output=OnBreak delay=0.000
trusted_keys.txt not present; pure server using only base trusted key list
L 07/16/2026 - 13:02:51: Log file closed.
Server logging data to file logs/L0716010.log
L 07/16/2026 - 13:02:51: Log file started (file "logs/L0716010.log") (game "/home/user/Downloads/hl2dm-serverfiles/hl2mp") (version "9540945")
L 07/16/2026 - 13:02:51: Loading map "dm_lockdown"
L 07/16/2026 - 13:02:51: server cvars start
L 07/16/2026 - 13:02:51: "sp_full_validation_version" = "unversioned"
L 07/16/2026 - 13:02:51: "sp_version" = "None"
L 07/16/2026 - 13:02:51: "mp_teamplay" = "0"
L 07/16/2026 - 13:02:51: "mp_falldamage" = "0"
L 07/16/2026 - 13:02:51: "mp_weaponstay" = "0"
L 07/16/2026 - 13:02:51: "mp_forcerespawn" = "1"
L 07/16/2026 - 13:02:51: "mp_footsteps" = "1"
L 07/16/2026 - 13:02:51: "mp_flashlight" = "1"
L 07/16/2026 - 13:02:51: "mp_autocrosshair" = "1"
L 07/16/2026 - 13:02:51: "decalfrequency" = "120"
L 07/16/2026 - 13:02:51: "mp_teamlist" = "blue,red"
L 07/16/2026 - 13:02:51: "mp_allowNPCs" = "1"
L 07/16/2026 - 13:02:51: "mp_friendlyfire" = "0"
L 07/16/2026 - 13:02:51: "mp_fadetoblack" = "0"
L 07/16/2026 - 13:02:51: "sv_gravity" = "600"
L 07/16/2026 - 13:02:51: "sv_stopspeed" = "100"
L 07/16/2026 - 13:02:51: "sv_noclipaccelerate" = "5"
L 07/16/2026 - 13:02:51: "sv_noclipspeed" = "5"
L 07/16/2026 - 13:02:51: "sv_specaccelerate" = "5"
L 07/16/2026 - 13:02:51: "sv_specspeed" = "3"
L 07/16/2026 - 13:02:51: "sv_specnoclip" = "1"
L 07/16/2026 - 13:02:51: "sv_maxspeed" = "320"
L 07/16/2026 - 13:02:51: "sv_accelerate" = "10"
L 07/16/2026 - 13:02:51: "sv_airaccelerate" = "10"
L 07/16/2026 - 13:02:51: "sv_wateraccelerate" = "10"
L 07/16/2026 - 13:02:51: "sv_waterfriction" = "1"
L 07/16/2026 - 13:02:51: "sv_footsteps" = "1"
L 07/16/2026 - 13:02:51: "sv_rollspeed" = "200"
L 07/16/2026 - 13:02:51: "sv_rollangle" = "0"
L 07/16/2026 - 13:02:51: "sv_friction" = "4"
L 07/16/2026 - 13:02:51: "sv_bounce" = "0"
L 07/16/2026 - 13:02:51: "sv_stepsize" = "18"
L 07/16/2026 - 13:02:51: "r_VehicleViewDampen" = "1"
L 07/16/2026 - 13:02:51: "r_JeepViewDampenFreq" = "7.0"
L 07/16/2026 - 13:02:51: "r_JeepViewDampenDamp" = "1.0"
L 07/16/2026 - 13:02:51: "r_JeepViewZHeight" = "10.0"
L 07/16/2026 - 13:02:51: "r_AirboatViewDampenFreq" = "7.0"
L 07/16/2026 - 13:02:51: "r_AirboatViewDampenDamp" = "1.0"
L 07/16/2026 - 13:02:51: "r_AirboatViewZHeight" = "0.0"
L 07/16/2026 - 13:02:51: "mp_timelimit" = "15"
L 07/16/2026 - 13:02:51: "mp_fraglimit" = "0"
L 07/16/2026 - 13:02:51: "nextlevel" = ""
L 07/16/2026 - 13:02:51: "sv_maxusrcmdprocessticks" = "24"
L 07/16/2026 - 13:02:51: "sv_alltalk" = "1"
L 07/16/2026 - 13:02:51: "sv_vote_quorum_ratio" = "0.6"
L 07/16/2026 - 13:02:51: "mp_respawnwavetime" = "10.0"
L 07/16/2026 - 13:02:51: "mp_tournament" = "0"
L 07/16/2026 - 13:02:51: "tf_arena_preround_time" = "10"
L 07/16/2026 - 13:02:51: "tf_arena_round_time" = "0"
L 07/16/2026 - 13:02:51: "tf_arena_max_streak" = "3"
L 07/16/2026 - 13:02:51: "tf_arena_use_queue" = "1"
L 07/16/2026 - 13:02:51: "mp_maxrounds" = "0"
L 07/16/2026 - 13:02:51: "mp_winlimit" = "0"
L 07/16/2026 - 13:02:51: "mp_disable_respawn_times" = "0"
L 07/16/2026 - 13:02:51: "mp_stalemate_meleeonly" = "0"
L 07/16/2026 - 13:02:51: "mp_forceautoteam" = "0"
L 07/16/2026 - 13:02:51: "mp_autoteambalance" = "1"
L 07/16/2026 - 13:02:51: "mp_stalemate_enable" = "0"
L 07/16/2026 - 13:02:51: "mp_match_end_at_timelimit" = "0"
L 07/16/2026 - 13:02:51: "mp_holiday_nogifts" = "0"
L 07/16/2026 - 13:02:51: "mp_scrambleteams_auto" = "1"
L 07/16/2026 - 13:02:51: "mp_scrambleteams_auto_windifference" = "2"
L 07/16/2026 - 13:02:51: "sv_hl2mp_weapon_respawn_time" = "20"
L 07/16/2026 - 13:02:51: "sv_hl2mp_item_respawn_time" = "30"
L 07/16/2026 - 13:02:51: "sv_report_client_settings" = "1"
L 07/16/2026 - 13:02:51: "sv_steamgroup" = ""
L 07/16/2026 - 13:02:51: "sv_cheats" = "1"
L 07/16/2026 - 13:02:51: "sv_pausable" = "0"
L 07/16/2026 - 13:02:51: "sv_contact" = ""
L 07/16/2026 - 13:02:51: "sv_voiceenable" = "1"
L 07/16/2026 - 13:02:51: "tv_enable" = "1"
L 07/16/2026 - 13:02:51: "sv_tags" = "alltalk,increased_maxplayers"
L 07/16/2026 - 13:02:51: "tv_relaypassword" = ""
L 07/16/2026 - 13:02:51: "tv_password" = ""
L 07/16/2026 - 13:02:51: "deathmatch" = "1"
L 07/16/2026 - 13:02:51: "coop" = "0"
L 07/16/2026 - 13:02:51: server cvars end
Spawn Server: dm_lockdown: []
TODO: Refusing to discard 178312 bytes
Legacy game DLL may not support terrain vphysics collisions with this BSP!
L 07/16/2026 - 13:02:52: Started map "dm_lockdown" (CRC "78813e402b3f4b7d6f5ed3a32f4bdea8")
[sp_full_validation] level initialized: dm_lockdown
ConVarRef room_type doesn't point to an existing ConVar
execing skill_manifest.cfg
execing skill.cfg
Executing dedicated server config file server.cfg
Set Gravity 600.0 (0.250 tolerance)
Error loading cfg/vscript_convar_allowlist.txt
VSCRIPT: Started VScript virtual machine using script language 'Squirrel'
VSCRIPT: Started VScript virtual machine using script language 'Squirrel'
Script not found (scripts/vscripts/mapspawn.nut) 
Script not found (scripts/vscripts/mapspawn.nut) 
Loading AI graph
Step 1 loading
Checking version
AI node graph maps/graphs/dm_lockdown.ain is out of date
[sp_full_validation] entity output=OnFull delay=0.000
[sp_full_validation] entity output=OnFull delay=0.000
Set motd from file 'cfg/motd_default.txt'.  ('cfg/motd.txt' was not found.)
Set motd_text from file 'cfg/motd_text_default.txt'.  ('cfg/motd_text.txt' was not found.)
SV_ActivateServer: setting tickrate to 66.7
[sp_full_validation] entity output=OnLightOn delay=0.000
[sp_full_validation] entity output=OnLightOn delay=0.000
[sp_full_validation] entity output=OnLightOn delay=0.000
L 07/16/2026 - 13:02:53: "Peter Brev<3><[U:1:400275221]><>" entered the game
Sending full update to Client Peter Brev
sp_full_status
[sp_full_validation] arch=x86_64 map=dm_lockdown humans=1 entities=454 pulse=True recolor=True errors=0
[sp_full_validation] hooks raw=22/22 balance=0 output=22 player=626/626 balance=0 hibernation=1 last_hibernation=False
[sp_full_validation] messages bursts=1 say=1 hint=1 hud=1 timer_callbacks=1 chat_seen=0 recolored=0 say_commands=0 client_commands=0
[sp_full_validation] lifecycle active=3 disconnect=1 level_init=1 level_shutdown=1 entity_create=646 entity_delete=634 spawn=3 death=0
[sp_full_validation] model load=0 unload=2 precache_runs=1 runtime_runs=1 last_output=OnPlay last_event=player_spawn last_runtime=PASS pointer_bits=64 sha256=174677f0bd6b last_error=None
L 07/16/2026 - 13:03:04: "Peter Brev<3><[U:1:400275221]><Unassigned>" disconnected (reason "Client Disconnect")
Dropped Peter Brev from server (Client Disconnect)
Server is hibernating
[sp_full_validation] hibernation transition: True
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players
[sp_full_validation] timer message burst skipped: no human players

Test plugin used:

from collections import Counter

from _entities import BaseEntityOutput
from colors import CYAN
from colors import GREEN
from commands import CommandReturn
from commands.client import ClientCommand
from commands.say import SayCommand
from commands.say import SayFilter
from commands.server import ServerCommand
from core import ARCHITECTURE
from core import echo_console
from engines.server import engine_server
from engines.server import global_vars
from engines.server import server_game_dll
from events import Event
from filters.entities import EntityIter
from filters.players import PlayerIter
from listeners import OnClientActive
from listeners import OnClientDisconnect
from listeners import OnDataLoaded
from listeners import OnDataUnloaded
from listeners import OnEntityOutput
from listeners import OnLevelInit
from listeners import OnLevelShutdown
from listeners import OnNetworkedEntityCreated
from listeners import OnNetworkedEntityDeleted
from listeners import OnPlayerPostRunCommand
from listeners import OnPlayerRunCommand
from listeners.tick import Repeat
from memory import get_virtual_function
from memory.hooks import PostHook
from memory.hooks import PreHook
from messages import HintText
from messages import HudMsg
from messages import SayText2
from players.entity import Player
from studio.cache import model_cache


PLUGIN_NAME = "sp_full_validation"
INTERVAL = 10.0
TEST_MODEL = "models/props_junk/watermelon01.mdl"
CHAT_PREFIX = "\x04[SP PORT]\x01"

counts = Counter()
last = {
    "error": None,
    "event": None,
    "level": None,
    "output": None,
    "hibernation": None,
    "runtime": None,
}

pulse_enabled = True
recolor_enabled = True
_repeat = None


def _console(message):
    """Write one consistently prefixed line to the server console."""
    echo_console("[{}] {}".format(PLUGIN_NAME, message))


def _error(feature, exception):
    """Record and report a feature failure without stopping other tests."""
    counts["errors"] += 1
    counts["error_{}".format(feature)] += 1
    last["error"] = "{}: {!r}".format(feature, exception)
    _console("ERROR {}: {!r}".format(feature, exception))


def _human_indexes():
    """Return a stable snapshot of connected human-player indexes."""
    try:
        return [player.index for player in PlayerIter("human")]
    except Exception as exception:
        _error("player_iteration", exception)
        return []


def _clean_chat_text(command):
    """Extract text from a say or say_team command."""
    text = command.arg_string.strip()
    if len(text) >= 2 and text[0] == text[-1] == '"':
        text = text[1:-1]
    return text


def _send_user_messages(indexes=None, label="manual"):
    """Exercise SayText2, HintText, and HudMsg."""
    if indexes is None:
        indexes = _human_indexes()
    else:
        indexes = list(indexes)

    if not indexes:
        _console("{} message burst skipped: no human players".format(label))
        return

    counts["message_bursts"] += 1
    pulse_number = counts["timer_pulses"]

    try:
        SayText2(
            "{} {} burst #{} on {}".format(
                CHAT_PREFIX, label, counts["message_bursts"], ARCHITECTURE),
            chat=True,
        ).send(*indexes)
        counts["saytext2_sent"] += len(indexes)
    except Exception as exception:
        _error("saytext2", exception)

    try:
        HintText(
            "Source.Python {} validation\n{} burst #{}".format(
                ARCHITECTURE, label, counts["message_bursts"])
        ).send(*indexes)
        counts["hinttext_sent"] += len(indexes)
    except Exception as exception:
        _error("hinttext", exception)

    try:
        HudMsg(
            "SP {} | {} | pulse {}".format(
                ARCHITECTURE, label, pulse_number),
            x=-1.0,
            y=0.18,
            color1=CYAN,
            color2=GREEN,
            effect=0,
            fade_in=0.2,
            fade_out=0.5,
            hold_time=3.0,
            fx_time=0.0,
            channel=4,
        ).send(*indexes)
        counts["hudmsg_sent"] += len(indexes)
    except Exception as exception:
        _error("hudmsg", exception)


def _timer_tick():
    """Send the periodic user-message pulse."""
    counts["timer_callbacks"] += 1
    if not pulse_enabled:
        return

    counts["timer_pulses"] += 1
    _send_user_messages(label="timer")


def _run_runtime_self_test():
    """Exercise several CPython native modules bundled with the runtime."""
    try:
        import ctypes
        import hashlib
        import json
        import sqlite3
        import zlib

        pointer_bits = ctypes.sizeof(ctypes.c_void_p) * 8
        counts["runtime_ctypes"] += 1

        payload = {"architecture": ARCHITECTURE, "pointer_bits": pointer_bits}
        encoded = json.dumps(payload, sort_keys=True).encode("utf-8")
        if json.loads(encoded.decode("utf-8")) != payload:
            raise RuntimeError("JSON round trip changed the payload")
        counts["runtime_json"] += 1

        compressed = zlib.compress(encoded)
        if zlib.decompress(compressed) != encoded:
            raise RuntimeError("zlib round trip changed the payload")
        counts["runtime_zlib"] += 1

        digest = hashlib.sha256(encoded).hexdigest()
        if len(digest) != 64:
            raise RuntimeError("unexpected SHA-256 result")
        counts["runtime_hashlib"] += 1

        connection = sqlite3.connect(":memory:")
        try:
            connection.execute("CREATE TABLE test (value INTEGER)")
            connection.execute("INSERT INTO test VALUES (?)", (pointer_bits,))
            result = connection.execute("SELECT value FROM test").fetchone()[0]
        finally:
            connection.close()
        if result != pointer_bits:
            raise RuntimeError("SQLite round trip changed the value")
        counts["runtime_sqlite"] += 1

        counts["runtime_runs"] += 1
        last["runtime"] = "PASS pointer_bits={} sha256={}".format(
            pointer_bits, digest[:12])
        _console("runtime self-test {}".format(last["runtime"]))
    except Exception as exception:
        _error("runtime", exception)


def _print_status():
    """Print grouped counters and balance checks."""
    try:
        entity_count = sum(1 for _ in EntityIter())
    except Exception as exception:
        entity_count = -1
        _error("entity_iteration", exception)

    humans = len(_human_indexes())
    raw_balance = counts["raw_pre"] - counts["raw_post"]
    player_balance = counts["player_pre"] - counts["player_post"]

    _console(
        "arch={} map={} humans={} entities={} pulse={} recolor={} errors={}".format(
            ARCHITECTURE,
            global_vars.map_name,
            humans,
            entity_count,
            pulse_enabled,
            recolor_enabled,
            counts["errors"],
        )
    )
    _console(
        "hooks raw={}/{} balance={} output={} player={}/{} balance={} "
        "hibernation={} last_hibernation={}".format(
            counts["raw_pre"],
            counts["raw_post"],
            raw_balance,
            counts["entity_output"],
            counts["player_pre"],
            counts["player_post"],
            player_balance,
            counts["hibernation"],
            last["hibernation"],
        )
    )
    _console(
        "messages bursts={} say={} hint={} hud={} timer_callbacks={} "
        "chat_seen={} recolored={} say_commands={} client_commands={}".format(
            counts["message_bursts"],
            counts["saytext2_sent"],
            counts["hinttext_sent"],
            counts["hudmsg_sent"],
            counts["timer_callbacks"],
            counts["chat_seen"],
            counts["chat_recolored"],
            counts["say_commands"],
            counts["client_commands"],
        )
    )
    _console(
        "lifecycle active={} disconnect={} level_init={} level_shutdown={} "
        "entity_create={} entity_delete={} spawn={} death={}".format(
            counts["client_active"],
            counts["client_disconnect"],
            counts["level_init"],
            counts["level_shutdown"],
            counts["entity_created"],
            counts["entity_deleted"],
            counts["event_player_spawn"],
            counts["event_player_death"],
        )
    )
    _console(
        "model load={} unload={} precache_runs={} runtime_runs={} "
        "last_output={} last_event={} last_runtime={} last_error={}".format(
            counts["data_loaded"],
            counts["data_unloaded"],
            counts["precache_runs"],
            counts["runtime_runs"],
            last["output"],
            last["event"],
            last["runtime"],
            last["error"],
        )
    )


# ---------------------------------------------------------------------------
# Native DynamicHooks and engine listener coverage
# ---------------------------------------------------------------------------


@PreHook(BaseEntityOutput.fire_output)
def _pre_fire_output(stack_data):
    counts["raw_pre"] += 1


@PostHook(BaseEntityOutput.fire_output)
def _post_fire_output(stack_data, return_value):
    counts["raw_post"] += 1


@OnEntityOutput
def _on_entity_output(output_name, activator, caller, value, delay):
    counts["entity_output"] += 1
    last["output"] = output_name
    if counts["entity_output"] <= 8:
        _console("entity output={} delay={:.3f}".format(output_name, delay))


@OnPlayerRunCommand
def _on_player_run_command(player, user_cmd):
    counts["player_pre"] += 1


@OnPlayerPostRunCommand
def _on_player_post_run_command(player, user_cmd):
    counts["player_post"] += 1


_set_server_hibernation = get_virtual_function(
    server_game_dll, "SetServerHibernation")


@PreHook(_set_server_hibernation)
def _pre_set_server_hibernation(stack_data):
    counts["hibernation"] += 1
    last["hibernation"] = bool(stack_data[1])
    _console("hibernation transition: {}".format(last["hibernation"]))


@OnDataLoaded
def _on_data_loaded(data_type, handle):
    counts["data_loaded"] += 1
    if counts["data_loaded"] <= 8:
        _console("model data loaded: type={} handle={}".format(data_type, handle))


@OnDataUnloaded
def _on_data_unloaded(data_type, handle):
    counts["data_unloaded"] += 1


# ---------------------------------------------------------------------------
# High-level lifecycle, event, command, timer, and messaging coverage
# ---------------------------------------------------------------------------


@OnClientActive
def _on_client_active(index):
    counts["client_active"] += 1


@OnClientDisconnect
def _on_client_disconnect(index):
    counts["client_disconnect"] += 1


@OnLevelInit
def _on_level_init(map_name):
    counts["level_init"] += 1
    last["level"] = map_name
    _console("level initialized: {}".format(map_name))


@OnLevelShutdown
def _on_level_shutdown():
    counts["level_shutdown"] += 1


@OnNetworkedEntityCreated
def _on_networked_entity_created(entity):
    counts["entity_created"] += 1


@OnNetworkedEntityDeleted
def _on_networked_entity_deleted(entity):
    counts["entity_deleted"] += 1


@Event("player_spawn")
def _on_player_spawn(game_event):
    counts["event_player_spawn"] += 1
    last["event"] = "player_spawn"


@Event("player_death")
def _on_player_death(game_event):
    counts["event_player_death"] += 1
    last["event"] = "player_death"


@SayFilter
def _recolor_player_chat(command, index, team_only):
    """Replace ordinary chat while preserving commands and failure fallback."""
    counts["chat_seen"] += 1
    if not recolor_enabled:
        return CommandReturn.CONTINUE

    text = _clean_chat_text(command)
    if not text or text.startswith(("!", "/")):
        return CommandReturn.CONTINUE

    try:
        sender = Player(index)
        recipients = [
            player.index
            for player in PlayerIter("human")
            if not team_only or player.team == sender.team
        ]
        SayText2(
            "{} \x04{}\x01: {}".format(CHAT_PREFIX, sender.name, text),
            index=index,
            chat=True,
        ).send(*recipients)
        counts["chat_recolored"] += 1
        return CommandReturn.BLOCK
    except Exception as exception:
        _error("chat_recolor", exception)
        return CommandReturn.CONTINUE


@SayCommand("!sptest")
def _say_test(command, index, team_only=False):
    counts["say_commands"] += 1
    _send_user_messages([index], "say-command")
    SayText2(
        "{} counters: output={} player_hooks={}/{} errors={}".format(
            CHAT_PREFIX,
            counts["entity_output"],
            counts["player_pre"],
            counts["player_post"],
            counts["errors"],
        ),
        chat=True,
    ).send(index)
    return CommandReturn.BLOCK


@ClientCommand("sp_client_test")
def _client_test(command, index):
    counts["client_commands"] += 1
    _send_user_messages([index], "client-command")
    return CommandReturn.BLOCK


@ServerCommand("sp_full_status")
def _status_command(command):
    _print_status()


@ServerCommand("sp_full_burst")
def _burst_command(command):
    _send_user_messages(label="server-command")


@ServerCommand("sp_full_precache")
def _precache_command(command):
    try:
        before = counts["data_loaded"]
        model_index = engine_server.precache_model(TEST_MODEL, True)
        handle = model_cache.find_model(TEST_MODEL)
        try:
            model_cache.get_model_header(handle)
        finally:
            model_cache.release(handle)

        counts["precache_runs"] += 1
        _console(
            "precache model={} index={} data_loaded_delta={}".format(
                TEST_MODEL, model_index, counts["data_loaded"] - before)
        )
    except Exception as exception:
        _error("precache", exception)


@ServerCommand("sp_full_runtime")
def _runtime_command(command):
    _run_runtime_self_test()


@ServerCommand("sp_full_toggle_pulse")
def _toggle_pulse_command(command):
    global pulse_enabled
    pulse_enabled = not pulse_enabled
    _console("automatic message pulse enabled={}".format(pulse_enabled))


@ServerCommand("sp_full_toggle_recolor")
def _toggle_recolor_command(command):
    global recolor_enabled
    recolor_enabled = not recolor_enabled
    _console("chat recoloring enabled={}".format(recolor_enabled))


@ServerCommand("sp_full_reset")
def _reset_command(command):
    counts.clear()
    for key in last:
        last[key] = None
    _console("all counters and last-value fields reset")


def load():
    """Start the repeating pulse and perform the first runtime check."""
    global _repeat
    counts["loads"] += 1
    _run_runtime_self_test()
    _repeat = Repeat(_timer_tick)
    _repeat.start(INTERVAL)
    _console(
        "loaded on architecture {}; interval={} seconds".format(
            ARCHITECTURE, INTERVAL)
    )


def unload():
    """Stop the timer before Source.Python unregisters decorators/hooks."""
    counts["unloads"] += 1
    if _repeat is not None:
        try:
            _repeat.stop()
        except Exception as exception:
            _error("repeat_stop", exception)
    _console(
        "unloaded cleanly; errors={} raw={}/{} player={}/{}".format(
            counts["errors"],
            counts["raw_pre"],
            counts["raw_post"],
            counts["player_pre"],
            counts["player_post"],
        )
    )


_console("registered on architecture {}".format(ARCHITECTURE))

Source.Python unloading on server shutdown:

[Source.Python] Unloading...
[Source.Python] Resetting cache notifier...
[Source.Python] Shutting down python...
[Source.Python] Unloading main module...
[sp_full_validation] unloaded cleanly; errors=0 raw=30/30 player=4403/4403
[Source.Python] Clearing convar changed listener...
[Source.Python] Clearing server output listeners...
[Source.Python] Unhooking all functions...
[Source.Python] Clearing all commands...
[Source.Python] Unregistering ConVar...
[Source.Python] Disconnecting tier1 libraries...
[Source.Python] Unloaded successfully.

Screenshot of in-game interaction with Source.Python x64:
20260716130139_1

Note: I was able to launch Source.Python on a custom server_srv.so binary, but any detouring may or may not crash servers. I know I have had that problem on Sourcemod where I had to update all the vtable offsets and signatures because of my custom code. I have not gone as far as detouring any function on my custom server_srv.so, though, so keep that in mind.

Thank you.

@speedvoltage
speedvoltage marked this pull request as ready for review July 16, 2026 11:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant