diff --git a/.gitignore b/.gitignore index 2ac93e819..a8b684579 100644 --- a/.gitignore +++ b/.gitignore @@ -699,3 +699,6 @@ FodyWeavers.xsd # Selected Background /game/neo/scripts/[Cc]hapter[Bb]ackgrounds.txt + +# Developer-local tools (the hot reload sidecar lives in .ide/bin) +/.ide/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fced5b910..9d5d690fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,58 @@ It's recommended you fork [the master branch](https://github.com/NeotokyoRebuild See [README.md](README.md) in this repo for setting up your build environment (currently supporting Windows/Linux). +### Hot reload (Linux) + +Edit C++ while the game runs: keep `make watch` running in your build shell and +run the game through Steam. Saving a source file recompiles just that +translation unit and applies it to the live process in a few seconds. + +Setup: follow the [ntre-build-hot-reloader Quick start](https://github.com/sunmachine/ntre-build-hot-reloader#quick-start) (checkout next to +this repo or `RELOADER_DIR=`, Rust toolchain with the musl target, sniper image), +then + +```bash +make watch-configure # once: the neo-sniper container, the sidecar binary, the hotreload preset +make watch # every session +``` + +`make watch-configure` needs Podman: it creates a persistent `neo-sniper` +container from the sniper image that mounts your home at the host path and runs +as your uid, or `CONTAINER=` points at an existing Toolbx or distrobox container. +Each step is skipped when already done. + +Both Steam launch methods from the README work; the loader finds the build tree +in either layout: + +* Source SDK Base 2013 Multiplayer with launch options + `%command% -insecure -dev -game /path/to/your/repo/game/neo` +* the "Neotokyo: Rebuild" sourcemod entry (steamapps/sourcemods symlink or bind + mount of `game/neo`), with `-insecure -dev` as its launch options + +`make build` returns to the `linux-debug` preset. See `src/Makefile` (`make help`). +In-game: `sv_neo_hot_reload_status`, +`sv_neo_hot_reload` (apply when `sv_neo_hot_reload_auto` is 0); `cl_` variants +for client.so. The sidecar in `.ide/bin/` and the vendored loader under +`src/game/shared/neo/hotreload/vendor/` (`VENDORED.md` names the revision) come +from the same reloader repo; keep them in step. + +To catch a crash with symbols while the game runs, attach the host's gdb by pid +(`ptrace_scope` is 0 here) and launch with `-noassert`, or a pre-existing bot +animation assert opens a modal dialog under a debugger. + +What hot reload does not do, by design: change a type's layout or vtable, +apply edits to `BEGIN_DATADESC` / send and receive tables / prediction maps +(they are built once at static init), or reload a header that dirties more +than `--max-tus` units. Each of those is a rebuild. The reloader README lists +the full set under Limits. + +Troubleshooting `make watch-configure`: + +* `crun: ptsname: Inappropriate ioctl for device`: transient Podman error on the + first exec after boot; re-run the same command. +* `sidecar missing and no reloader checkout`: clone the reloader next to this + repo or set `RELOADER_DIR=`. + ### Debugging To be safe and avoid problems with VAC, it's recommended to add a [-insecure](https://developer.valvesoftware.com/wiki/Command_Line_Options) launch flag before attaching your debugger. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..e6227632b --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +# Convenience aliases only. Every target here forwards to src/Makefile, and +# src/Makefile in turn only wraps the CMake presets in src/CMakePresets.json. +# Nothing in either Makefile is part of the build itself: the CMake layer is +# the build, and cmake --preset / cmake --build --preset stay the primary, +# supported way to configure and build. Do not add build logic here. + +%: + @$(MAKE) -C src $@ + +.DEFAULT_GOAL := help diff --git a/README.md b/README.md index ac7d87a14..635fce484 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,17 @@ $ cmake --build --preset PRESET_NAME Available PRESET_NAME values: `windows-debug`, `windows-release`, `linux-debug`, `linux-release`. +#### Hot reload (Linux) +Edit C++ while the game runs. Setup is the [ntre-build-hot-reloader Quick start](https://github.com/sunmachine/ntre-build-hot-reloader#quick-start): +check out that repo next to this one, install its Rust toolchain, then + +``` +$ make watch-configure # once +$ make watch # every session +``` + +and launch the game from Steam with `-insecure -dev`. Needs Podman with the sniper image (see [Linux prerequisite](#linux-prerequisite---steam-runtime-3-sniper-container)). More in [CONTRIBUTING.md](CONTRIBUTING.md#hot-reload-linux). + ## Steam mod setup To make it appear in Steam, the install files have to appear under the sourcemods directory or be directed to it. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5bda91539..bb2b2d4db 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -55,6 +55,27 @@ option(NEO_GENERATE_GAMEDATA "Generate SourceMod gamedata" ${NEO_DEDICATED}) option(NEO_BUILD_LAUNCHER "Build the Steam mod launcher" ON) option(NEO_UNITY_BUILD_CLIENT_SERVER "Enable unity build for client/server libraries" ON) option(NEO_UNITY_BUILD_OTHERS "Enable unity build for vgui2, tier1, and mathlib libraries" ON) +option(NEO_ENABLE_LINUX_HOT_RELOAD "Enable the Linux hot reload loader in client and server (Debug, non-unity, Linux only)" OFF) + +if(NEO_ENABLE_LINUX_HOT_RELOAD) + if(NOT OS_LINUX OR NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message(FATAL_ERROR "NEO_ENABLE_LINUX_HOT_RELOAD needs OS_LINUX and CMAKE_BUILD_TYPE Debug") + endif() + if(NEO_UNITY_BUILD_CLIENT_SERVER) + message(FATAL_ERROR "NEO_ENABLE_LINUX_HOT_RELOAD needs NEO_UNITY_BUILD_CLIENT_SERVER=OFF (the sidecar maps saves to per-TU objects)") + endif() + if(NEO_USE_SEPARATE_BUILD_INFO) + message(FATAL_ERROR "NEO_ENABLE_LINUX_HOT_RELOAD needs NEO_USE_SEPARATE_BUILD_INFO=OFF (the loader reads the deployed module's .symtab)") + endif() + if(NEO_DEDICATED) + message(FATAL_ERROR "NEO_ENABLE_LINUX_HOT_RELOAD does not support NEO_DEDICATED (module must be named server.so)") + endif() + # The loader receives the build dir relative to the deployed module's directory, + # so the same binary works from any absolute repo location (host vs dev container). + get_filename_component(NEO_HR_OUTPUT_ABS "${NEO_OUTPUT_LIBRARY_PATH}" ABSOLUTE) + file(RELATIVE_PATH NEO_HOT_RELOAD_BUILD_DIR_REL "${NEO_HR_OUTPUT_ABS}" "${CMAKE_BINARY_DIR}") + message(STATUS "Linux hot reload: ON (mailbox at /${NEO_HOT_RELOAD_BUILD_DIR_REL}/.hotreload)") +endif() set(NEO_MOD_APPID "3172910" CACHE STRING "Steam appid for the mod launcher's steam_appid.txt") message(STATUS "Treat compile warnings as errors: ${CMAKE_COMPILE_WARNING_AS_ERROR}") @@ -295,7 +316,24 @@ if(OS_WINDOWS) endif() if(OS_LINUX OR OS_MACOS) - set(CMAKE_CXX_VISIBILITY_PRESET hidden) + if(NEO_ENABLE_LINUX_HOT_RELOAD) + # Default visibility so shims can bind the module's definitions by name. + # -falign-functions=16 guarantees room for the entry hook, -fno-gnu-unique + # avoids process-wide STB_GNU_UNIQUE bindings that would pin a shim, + # -fdata-sections gives every static its own named section so the loader + # can share it exactly. + add_compile_options( + -falign-functions=16 + -fno-gnu-unique + -fdata-sections + # fmtstr.h pins its classes hidden with a visibility pragma; under + # default visibility every exported class with such a field trips + # -Wattributes, which is noise for this dev-only preset. + -Wno-attributes + ) + else() + set(CMAKE_CXX_VISIBILITY_PRESET hidden) + endif() # Set default optimization option to O2 instead of O3 string(REGEX REPLACE "([\\/\\-]O)3" "\\12" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") @@ -396,11 +434,21 @@ if(OS_LINUX OR OS_MACOS) # We should always specify -Wl,--build-id, as documented at: # http://linux.die.net/man/1/ld and http://fedoraproject.org/wiki/Releases/FeatureBuildId + if(NEO_ENABLE_LINUX_HOT_RELOAD) + # Same script minus the operator* localization (shims resolve the game's + # operator overloads from the module); -Bsymbolic keeps each module bound + # to its own definitions under default visibility. + set(NEO_VERSION_SCRIPT "${CMAKE_SOURCE_DIR}/version_script.linux.hotreload.txt") + add_link_options(-Wl,-Bsymbolic) + else() + set(NEO_VERSION_SCRIPT "${CMAKE_SOURCE_DIR}/version_script.linux.txt") + endif() + add_link_options( -Wl,--build-id -static-libgcc -Wl,--no-undefined - -Wl,--version-script=${CMAKE_SOURCE_DIR}/version_script.linux.txt + -Wl,--version-script=${NEO_VERSION_SCRIPT} ) # Fix undefined references in static libraries @@ -412,6 +460,28 @@ if(OS_LINUX) set(LIBPUBLIC "${CMAKE_SOURCE_DIR}/${LIBPUBLIC_RELATIVE_PATH}") set(LIBCOMMON "${CMAKE_SOURCE_DIR}/lib/common/${PLATSUBDIR}") + if(NEO_ENABLE_LINUX_HOT_RELOAD) + # The prebuilt SDK archives were compiled with hidden visibility, so + # their globals (mdlcache, materials, g_pParticleSystemMgr, ...) would + # stay out of the module's dynamic symbol table and a shim rebuilt from + # a translation unit that references one could not link. Link this + # preset against byte-identical copies whose GLOBAL/WEAK symbols are + # promoted to default visibility; st_other is the only changed byte. + set(NEO_HR_LIBPUBLIC "${CMAKE_BINARY_DIR}/libpublic-hotreload") + execute_process( + COMMAND python3 "${CMAKE_SOURCE_DIR}/../tools/hotreload-default-visibility.py" + "${LIBPUBLIC}" "${NEO_HR_LIBPUBLIC}" + RESULT_VARIABLE NEO_HR_LIBPATCH_RESULT + OUTPUT_VARIABLE NEO_HR_LIBPATCH_OUTPUT + ERROR_VARIABLE NEO_HR_LIBPATCH_OUTPUT + ) + if(NOT NEO_HR_LIBPATCH_RESULT EQUAL 0) + message(FATAL_ERROR "hot reload library visibility patch failed:\n${NEO_HR_LIBPATCH_OUTPUT}") + endif() + message(STATUS "Linux hot reload: linking against ${NEO_HR_LIBPUBLIC}") + set(LIBPUBLIC "${NEO_HR_LIBPUBLIC}") + endif() + add_compile_definitions( LINUX _LINUX diff --git a/src/CMakePresets.json b/src/CMakePresets.json index 0b13d52a4..497e9a38b 100644 --- a/src/CMakePresets.json +++ b/src/CMakePresets.json @@ -63,6 +63,17 @@ "CMAKE_BUILD_TYPE": "Debug" } }, + { + "name": "linux-debug-hotreload", + "displayName": "Linux Debug (hot reload)", + "inherits": "linux-debug", + "cacheVariables": { + "NEO_ENABLE_LINUX_HOT_RELOAD": "ON", + "NEO_UNITY_BUILD_CLIENT_SERVER": "OFF", + "NEO_UNITY_BUILD_OTHERS": "OFF", + "NEO_USE_CCACHE": "OFF" + } + }, { "name": "linux-release", "displayName": "Linux Release", @@ -88,6 +99,11 @@ "displayName": "Linux Debug", "configurePreset": "linux-debug" }, + { + "name": "linux-debug-hotreload", + "displayName": "Linux Debug (hot reload)", + "configurePreset": "linux-debug-hotreload" + }, { "name": "linux-release", "displayName": "Linux Release", diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 000000000..2124a34ee --- /dev/null +++ b/src/Makefile @@ -0,0 +1,104 @@ +# Convenience aliases over the CMake presets in CMakePresets.json. This +# Makefile is not a build system and must not become one: the CMake layer is +# the build, and cmake --preset / cmake --build --preset remain the primary, +# supported way to configure and build. The aliases only add what CMake +# cannot express on its own: running the toolchain inside the build container +# (the host has no cmake or ninja), recording which preset is applied, and +# launching the hot reload sidecar. Anything that affects what gets compiled +# or how belongs in CMakeLists.txt / CMakePresets.json, not here. +# +# make build [PRESET=linux-debug] configure if needed, build, deploy, record the applied preset +# make watch [MODE=save|trigger] ensure the linux-debug-hotreload preset is applied, run the sidecar +# make watch-configure one-time hot reload setup: container, sidecar, preset +# make configure [PRESET=...] configure only +# make status applied preset and sidecar view of the mailbox +# make clean [PRESET=...] remove that preset's build dir +# +# The linux-debug and linux-debug-hotreload presets use separate build dirs and +# both deploy to game/neo/bin/linux64, so switching presets is just make build / +# make watch; .ntre-applied-preset records which one is live. + +PRESET ?= linux-debug +WATCH_PRESET ?= linux-debug-hotreload +MODE ?= save +JOBS ?= 12 +CONTAINER ?= neo-sniper +SDK_DIR ?= $(HOME)/.steam/steam/steamapps/common/Source SDK Base 2013 Multiplayer + +REPO_ROOT := $(abspath ..) +DEPLOY_DIR := $(REPO_ROOT)/game/neo/bin/linux64 +STAMP := $(DEPLOY_DIR)/.ntre-applied-preset + +# The sidecar binary is not vendored; a static musl build works both on the +# host and inside the container. Override with SIDECAR=... or the +# NTRE_HOTRELOAD_SIDECAR env var. +NTRE_HOTRELOAD_SIDECAR ?= $(REPO_ROOT)/.ide/bin/ntre-hr-sidecar +SIDECAR ?= $(NTRE_HOTRELOAD_SIDECAR) +# Checkout of ntre-build-hot-reloader, used by watch-configure to build the sidecar. +RELOADER_DIR ?= $(REPO_ROOT)/../ntre-build-hot-reloader +SNIPER_IMAGE ?= registry.gitlab.steamos.cloud/steamrt/sniper/sdk + +# Everything that needs cmake, ninja, g++ or the sidecar runs inside a +# persistent build container that mounts $HOME at the host path (so the repo +# and the Steam SDK are visible) and runs as the host uid; watch-configure +# creates one with plain podman, and a distrobox or toolbox also qualifies. +IN_CONTAINER = podman start $(CONTAINER) >/dev/null && \ + podman exec -i $(shell [ -t 0 ] && echo -t) --user $(shell id -u):$(shell id -g) \ + -e HOME=$(HOME) -w $(CURDIR) $(CONTAINER) + +.PHONY: build watch watch-configure configure status clean help + +# One-time setup for make watch. Each step is skipped when already done. +watch-configure: + @podman container exists $(CONTAINER) 2>/dev/null && echo "container: $(CONTAINER)" || { \ + real=$$(realpath "$(HOME)"); extra=""; [ "$$real" = "$(HOME)" ] || extra="-v $$real:$$real"; \ + podman create --name $(CONTAINER) --userns=keep-id --security-opt label=disable \ + -v "$(HOME):$(HOME)" $$extra -v "$(REPO_ROOT):$(REPO_ROOT)" -w "$(REPO_ROOT)" \ + $(SNIPER_IMAGE) sleep infinity >/dev/null && \ + echo "container: created $(CONTAINER) from $(SNIPER_IMAGE), home and the repo mounted at host paths"; } + @test -x "$(SIDECAR)" && echo "sidecar: $(SIDECAR)" || { \ + test -f "$(RELOADER_DIR)/Makefile" || { echo "sidecar missing and no reloader checkout at $(RELOADER_DIR) (set RELOADER_DIR=...)"; exit 1; }; \ + $(MAKE) --no-print-directory -C "$(RELOADER_DIR)" sidecar-musl && \ + mkdir -p "$(dir $(SIDECAR))" && \ + cp "$(RELOADER_DIR)/sidecar/target/x86_64-unknown-linux-musl/release/ntre-hr-sidecar" "$(SIDECAR)" && \ + echo "sidecar: built from $(RELOADER_DIR) into $(SIDECAR)"; } + @$(IN_CONTAINER) sh -c 'test -f build/$(WATCH_PRESET)/build.ninja || cmake --preset $(WATCH_PRESET)' && echo "preset: $(WATCH_PRESET) configured" + +build: + @$(IN_CONTAINER) sh -c 'test -f build/$(PRESET)/build.ninja || cmake --preset $(PRESET)' + $(IN_CONTAINER) cmake --build --preset $(PRESET) -j $(JOBS) + @printf '%s\n' '$(PRESET)' > $(STAMP) + @echo "deployed: $(PRESET)" + +watch: + @test -x "$(SIDECAR)" || { \ + echo "sidecar not found at $(SIDECAR)"; \ + echo "get it: make -C sidecar-musl,"; \ + echo "then copy sidecar/target/x86_64-unknown-linux-musl/release/ntre-hr-sidecar"; \ + echo "to $(REPO_ROOT)/.ide/bin/ (or set NTRE_HOTRELOAD_SIDECAR)"; \ + exit 1; } + @applied=$$(cat $(STAMP) 2>/dev/null || echo none); \ + if [ "$$applied" != "$(WATCH_PRESET)" ]; then \ + echo "applied preset is $$applied, building $(WATCH_PRESET) first"; \ + $(MAKE) --no-print-directory build PRESET=$(WATCH_PRESET); \ + else \ + echo "deployed: $$applied"; \ + fi + $(IN_CONTAINER) "$(SIDECAR)" --build-dir $(CURDIR)/build/$(WATCH_PRESET) watch \ + --mode $(MODE) \ + --link-dir $(DEPLOY_DIR) \ + --link-dir "$(SDK_DIR)/bin/linux64" \ + --never-shim game/shared/neo/hotreload/ + +configure: + $(IN_CONTAINER) cmake --preset $(PRESET) + +status: + @echo "deployed: $$(cat $(STAMP) 2>/dev/null || echo none)" + @test -x "$(SIDECAR)" && $(IN_CONTAINER) "$(SIDECAR)" --build-dir $(CURDIR)/build/$(WATCH_PRESET) status || true + +clean: + rm -rf build/$(PRESET) + +help: + @sed -n '1,19p' Makefile diff --git a/src/game/client/CMakeLists.txt b/src/game/client/CMakeLists.txt index 317cbb87a..a1ce3c1fa 100644 --- a/src/game/client/CMakeLists.txt +++ b/src/game/client/CMakeLists.txt @@ -1965,3 +1965,28 @@ target_sources_grouped( ${CMAKE_SOURCE_DIR}/game/client/NextBot/C_NextBot.cpp ${CMAKE_SOURCE_DIR}/game/client/NextBot/C_NextBot.h ) + +if(NEO_ENABLE_LINUX_HOT_RELOAD) + set(NEO_HR_DIR ${CMAKE_SOURCE_DIR}/game/shared/neo/hotreload) + file(GLOB NEO_HR_VENDOR_SOURCES CONFIGURE_DEPENDS ${NEO_HR_DIR}/vendor/src/*.cpp) + + target_sources_grouped( + TARGET client + NAME "Hot Reload" + FILES + ${NEO_HR_DIR}/neo_hot_reload.cpp + ${NEO_HR_DIR}/neo_hot_reload.h + ${NEO_HR_VENDOR_SOURCES} + ) + + target_include_directories(client PRIVATE ${NEO_HR_DIR}/vendor) + + target_compile_definitions(client PRIVATE + NEO_LINUX_HOT_RELOAD + NEO_HOT_RELOAD_BUILD_DIR_REL="${NEO_HOT_RELOAD_BUILD_DIR_REL}" + NEO_HOT_RELOAD_BUILD_DIR_ABS="${CMAKE_BINARY_DIR}" + ) + + # The vendored loader core is plain C++ that does not include the game PCH. + set_source_files_properties(${NEO_HR_VENDOR_SOURCES} PROPERTIES SKIP_PRECOMPILE_HEADERS ON) +endif() diff --git a/src/game/client/cdll_client_int.cpp b/src/game/client/cdll_client_int.cpp index fbc1f34ac..f928ed185 100644 --- a/src/game/client/cdll_client_int.cpp +++ b/src/game/client/cdll_client_int.cpp @@ -5,6 +5,9 @@ // $NoKeywords: $ //===========================================================================// #include "cbase.h" +#ifdef NEO_LINUX_HOT_RELOAD +#include "neo/hotreload/neo_hot_reload.h" +#endif #include #include "vgui_int.h" #include "clientmode.h" @@ -1213,6 +1216,10 @@ int CHLClient::Init( CreateInterfaceFn appSystemFactory, CreateInterfaceFn physi VerifyValidDxLevel(); #endif +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_Init(); +#endif + return true; } @@ -1681,6 +1688,9 @@ void CHLClient::Shutdown( void ) // DisconnectTier3Libraries( ); DisconnectTier2Libraries( ); ConVar_Unregister(); +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_Shutdown(); +#endif DisconnectTier1Libraries( ); gameeventmanager = NULL; @@ -1726,6 +1736,10 @@ void CHLClient::HudUpdate( bool bActive ) { float frametime = gpGlobals->frametime; +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_Frame(); +#endif + #if defined( TF_CLIENT_DLL ) CRTime::UpdateRealTime(); #endif diff --git a/src/game/server/CMakeLists.txt b/src/game/server/CMakeLists.txt index a05ccbb8a..54663867c 100644 --- a/src/game/server/CMakeLists.txt +++ b/src/game/server/CMakeLists.txt @@ -1871,3 +1871,28 @@ target_sources_grouped( ${UNITY_SOURCE_NEXTBOT} ) + +if(NEO_ENABLE_LINUX_HOT_RELOAD) + set(NEO_HR_DIR ${CMAKE_SOURCE_DIR}/game/shared/neo/hotreload) + file(GLOB NEO_HR_VENDOR_SOURCES CONFIGURE_DEPENDS ${NEO_HR_DIR}/vendor/src/*.cpp) + + target_sources_grouped( + TARGET server + NAME "Hot Reload" + FILES + ${NEO_HR_DIR}/neo_hot_reload.cpp + ${NEO_HR_DIR}/neo_hot_reload.h + ${NEO_HR_VENDOR_SOURCES} + ) + + target_include_directories(server PRIVATE ${NEO_HR_DIR}/vendor) + + target_compile_definitions(server PRIVATE + NEO_LINUX_HOT_RELOAD + NEO_HOT_RELOAD_BUILD_DIR_REL="${NEO_HOT_RELOAD_BUILD_DIR_REL}" + NEO_HOT_RELOAD_BUILD_DIR_ABS="${CMAKE_BINARY_DIR}" + ) + + # The vendored loader core is plain C++ that does not include the game PCH. + set_source_files_properties(${NEO_HR_VENDOR_SOURCES} PROPERTIES SKIP_PRECOMPILE_HEADERS ON) +endif() diff --git a/src/game/server/gameinterface.cpp b/src/game/server/gameinterface.cpp index 3890342c4..c6f2a6404 100644 --- a/src/game/server/gameinterface.cpp +++ b/src/game/server/gameinterface.cpp @@ -7,6 +7,9 @@ //===========================================================================// #include "cbase.h" +#ifdef NEO_LINUX_HOT_RELOAD +#include "neo/hotreload/neo_hot_reload.h" +#endif #include "gamestringpool.h" #include "mapentities_shared.h" #include "game.h" @@ -764,6 +767,10 @@ bool CServerGameDLL::DLLInit( CreateInterfaceFn appSystemFactory, gamestatsuploader->InitConnection(); #endif +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_Init(); +#endif + return true; } @@ -832,6 +839,11 @@ void CServerGameDLL::DLLShutdown( void ) DisconnectTier3Libraries(); DisconnectTier2Libraries(); ConVar_Unregister(); +#ifdef NEO_LINUX_HOT_RELOAD + // After the cvars are gone (shims registered some) and before the engine unloads us: + // runs the shims' static destructors while everything they reference is alive. + NeoHotReload_Shutdown(); +#endif DisconnectTier1Libraries(); } @@ -991,6 +1003,9 @@ float g_flServerCurTime = 0.0f; bool CServerGameDLL::LevelInit( const char *pMapName, char const *pMapEntities, char const *pOldLevel, char const *pLandmarkName, bool loadGame, bool background ) { VPROF("CServerGameDLL::LevelInit"); +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_LevelInitNotice(); +#endif g_flServerCurTime = gpGlobals->curtime; @@ -1236,6 +1251,10 @@ void CServerGameDLL::GameFrame( bool simulating ) { VPROF( "CServerGameDLL::GameFrame" ); +#ifdef NEO_LINUX_HOT_RELOAD + NeoHotReload_Frame(); +#endif + // Don't run frames until fully restored if ( g_InRestore ) return; diff --git a/src/game/server/util.cpp b/src/game/server/util.cpp index 8473c23e1..3d22c4430 100644 --- a/src/game/server/util.cpp +++ b/src/game/server/util.cpp @@ -6,6 +6,9 @@ //=============================================================================// #include "cbase.h" +#ifdef NEO_LINUX_HOT_RELOAD +#include "neo/hotreload/neo_hot_reload.h" +#endif #include "saverestore.h" #include "globalstate.h" #include @@ -153,6 +156,13 @@ IEntityFactory *CEntityFactoryDictionary::FindFactory( const char *pClassName ) //----------------------------------------------------------------------------- void CEntityFactoryDictionary::InstallFactory( IEntityFactory *pFactory, const char *pClassName ) { +#ifdef NEO_LINUX_HOT_RELOAD + // A shim's static ctors re-run LINK_ENTITY_TO_CLASS while it is applied. + // The original factory keeps working (its members were re-pointed at the + // shim's code), so keep it and drop the duplicate. + if ( NeoHotReload_InApply() && FindFactory( pClassName ) != NULL ) + return; +#endif Assert( FindFactory( pClassName ) == NULL ); m_Factories.Insert( pClassName, pFactory ); } diff --git a/src/game/shared/igamesystem.cpp b/src/game/shared/igamesystem.cpp index 0c550d265..7a113b57c 100644 --- a/src/game/shared/igamesystem.cpp +++ b/src/game/shared/igamesystem.cpp @@ -131,6 +131,27 @@ void IGameSystem::Remove( IGameSystem* pSys ) } } +#ifdef NEO_LINUX_HOT_RELOAD +void IGameSystem::HotReloadSnapshot( CUtlVector &out ) +{ + out.RemoveAll(); + out.AddVectorToTail( s_GameSystems ); +} + +void IGameSystem::HotReloadPrune( const CUtlVector &snapshot ) +{ + CUtlVector current; + current.AddVectorToTail( s_GameSystems ); + for ( int i = 0; i < current.Count(); ++i ) + { + if ( !snapshot.HasElement( current[i] ) ) + { + Remove( current[i] ); + } + } +} +#endif + //----------------------------------------------------------------------------- // Removes *all* systems from the list of systems to update //----------------------------------------------------------------------------- diff --git a/src/game/shared/igamesystem.h b/src/game/shared/igamesystem.h index 6dc983508..49b323351 100644 --- a/src/game/shared/igamesystem.h +++ b/src/game/shared/igamesystem.h @@ -70,6 +70,13 @@ abstract_class IGameSystem static void Remove ( IGameSystem* pSys ); static void RemoveAll ( ); +#ifdef NEO_LINUX_HOT_RELOAD + // Hot reload registry fixup: copy the live system list, and remove systems + // that appeared since the snapshot (a shim's static ctors re-registering). + static void HotReloadSnapshot( CUtlVector &out ); + static void HotReloadPrune( const CUtlVector &snapshot ); +#endif + // These methods are used to initialize, shutdown, etc all systems static bool InitAllSystems(); static void PostInitAllSystems(); diff --git a/src/game/shared/neo/hotreload/neo_hot_reload.cpp b/src/game/shared/neo/hotreload/neo_hot_reload.cpp new file mode 100644 index 000000000..cf331576a --- /dev/null +++ b/src/game/shared/neo/hotreload/neo_hot_reload.cpp @@ -0,0 +1,371 @@ +// Linux hot reload glue (see neo_hot_reload.h). Wraps the vendored loader core +// with Source engine behavior: console logging, mode ConVars, the trigger and +// status commands, and the registry snapshot/rebuild around each apply +// (ServerClass/ClientClass chain, game systems, entity factories). +#include "cbase.h" + +#ifdef NEO_LINUX_HOT_RELOAD + +#include "neo_hot_reload.h" + +#include "igamesystem.h" +#ifdef GAME_DLL +#include "server_class.h" +#include "NextBot/NextBotManager.h" +#else +#include "client_class.h" +#endif + +#include "ntre_hr.h" + +#include +#include +#include + +// memdbgon must be the last include file in a .cpp file!!! +#include "tier0/memdbgon.h" + +#ifdef GAME_DLL +#define HR_PREFIX "sv_neo_hot_reload" +#define HR_MODULE "server" +#else +#define HR_PREFIX "cl_neo_hot_reload" +#define HR_MODULE "client" +#endif + +#ifndef NEO_HOT_RELOAD_BUILD_DIR_REL +#error NEO_HOT_RELOAD_BUILD_DIR_REL must name the build dir relative to the module dir (set by CMake) +#endif +#ifndef NEO_HOT_RELOAD_BUILD_DIR_ABS +#error NEO_HOT_RELOAD_BUILD_DIR_ABS must name the absolute build dir (set by CMake) +#endif + +static void HotReloadAutoChanged(IConVar *var, const char *pOldValue, float flOldValue); +static void HotReloadVerboseChanged(IConVar *var, const char *pOldValue, float flOldValue); + +ConVar neo_hr_auto(HR_PREFIX "_auto", "1", FCVAR_DONTRECORD, + "Apply hot reload shims as the sidecar publishes them. 0 = hold them until " HR_PREFIX ".", + HotReloadAutoChanged); +ConVar neo_hr_verbose(HR_PREFIX "_verbose", "0", FCVAR_DONTRECORD, + "Per-symbol hot reload detail in the console.", + HotReloadVerboseChanged); + +namespace { + +ntre_hr *g_hr = nullptr; +bool g_inApply = false; + +#ifdef GAME_DLL +CUtlVector g_classSnapshot; +#else +CUtlVector g_classSnapshot; +#endif +CUtlVector g_systemSnapshot; +CUtlVector g_cvarSnapshot; + +// Singletons a static object's constructor publishes into another translation unit's +// global. The shim's copy of that object runs its constructor at dlopen and would move +// the live pointer to an empty copy; the original is put back after every apply. +struct SingletonRestore +{ + const char *name; + void *(*get)(); + void (*set)(void *); + void *saved; +}; +#ifdef GAME_DLL +void *GetNextBotSingleton() { return NextBotManager::GetInstance(); } +void SetNextBotSingleton(void *p) { NextBotManager::SetInstance(static_cast(p)); } +#endif +SingletonRestore g_singletons[] = { +#ifdef GAME_DLL + { "NextBotManager::sInstance", GetNextBotSingleton, SetNextBotSingleton, nullptr }, +#endif + { nullptr, nullptr, nullptr, nullptr }, +}; + +void HotReloadSnapshotSingletons() +{ + for (SingletonRestore *r = g_singletons; r->name; ++r) + r->saved = r->get(); +} + +int HotReloadRestoreSingletons() +{ + int restored = 0; + for (SingletonRestore *r = g_singletons; r->name; ++r) + { + if (r->get() != r->saved) + { + r->set(r->saved); + ++restored; + } + } + return restored; +} + +void HotReloadLog(void *, ntre_hr_log_level level, const char *message) +{ + switch (level) + { + case NTRE_HR_LOG_DEBUG: + DevMsg("[hotreload " HR_MODULE "] %s\n", message); + break; + case NTRE_HR_LOG_INFO: + Msg("[hotreload " HR_MODULE "] %s\n", message); + break; + default: + Warning("[hotreload " HR_MODULE "] %s\n", message); + break; + } +} + +// Before the shim is mapped (its static ctors have not run): remember the +// registries the ctors are about to touch. +void HotReloadPreApply(void *, const ntre_hr_shim_info *) +{ + g_inApply = true; + + g_classSnapshot.RemoveAll(); +#ifdef GAME_DLL + for (ServerClass *sc = g_pServerClassHead; sc; sc = sc->m_pNext) + g_classSnapshot.AddToTail(sc); +#else + for (ClientClass *cc = g_pClientClassHead; cc; cc = cc->m_pNext) + g_classSnapshot.AddToTail(cc); +#endif + + IGameSystem::HotReloadSnapshot(g_systemSnapshot); + + g_cvarSnapshot.RemoveAll(); + for (ConCommandBase *c = g_pCVar->GetCommands(); c; c = c->GetNext()) + g_cvarSnapshot.AddToTail(c); + + HotReloadSnapshotSingletons(); +} + +// A cvar we may unregister must belong to this game module or one of its live +// shims, never to the engine. dladdr maps an address back to the .so it came +// from: the base module (client.so / server.so) and every shim (client..so) +// share the module-name prefix, while engine cvars such as snd_soundmixer resolve +// to engine.so and stay untouched. Pruning the whole shared registry is how an +// attach-time apply could reach engine-owned cvars at all; scoped to our own +// module it cannot. +static bool HotReloadCvarIsOurs(const ConCommandBase *c) +{ + Dl_info info; + if (!dladdr(reinterpret_cast(c), &info) || !info.dli_fname) + return false; + const char *base = strrchr(info.dli_fname, '/'); + base = base ? base + 1 : info.dli_fname; + const size_t n = sizeof(HR_MODULE) - 1; // strlen of "client" / "server" + return Q_strncmp(base, HR_MODULE, n) == 0 && base[n] == '.'; +} + +// The shim's static ctors re-register every ConVar and ConCommand its +// translation units define. A same-named duplicate is not benign: the engine +// dispatches the last registration, and for a ConCommand like say that drops +// the game-DLL client context (chat suddenly comes from "Console"). Keep the +// originals (their callbacks are hooked, so they already run the new code) +// and unregister the duplicates; genuinely new names stay, which is what +// makes "add a ConVar and reload" work. Collect first, then unregister, so the +// live list is walked once without being mutated underfoot, and only ever touch +// cvars this module owns. +int HotReloadPruneCvarDuplicates() +{ + CUtlVector dupes; + for (ConCommandBase *c = g_pCVar->GetCommands(); c; c = c->GetNext()) + { + if (g_cvarSnapshot.HasElement(c)) + continue; + if (!HotReloadCvarIsOurs(c)) + continue; + for (int i = 0; i < g_cvarSnapshot.Count(); ++i) + { + if (Q_stricmp(g_cvarSnapshot[i]->GetName(), c->GetName()) == 0) + { + dupes.AddToTail(c); + break; + } + } + } + for (int i = 0; i < dupes.Count(); ++i) + g_pCVar->UnregisterConCommand(dupes[i]); + return dupes.Count(); +} + +// After hooks and relocations are in place: rebuild the class chain from the +// snapshot (the shim's duplicate ctor-inserted nodes drop out, the original +// nodes and their engine-assigned ids survive) and remove game systems the +// shim's ctors registered twice. +void HotReloadPostApply(void *, const ntre_hr_shim_info *shim, const ntre_hr_apply_counts *counts) +{ + if (g_classSnapshot.Count() > 0) + { + for (int i = 0; i < g_classSnapshot.Count() - 1; ++i) + g_classSnapshot[i]->m_pNext = g_classSnapshot[i + 1]; + g_classSnapshot[g_classSnapshot.Count() - 1]->m_pNext = nullptr; +#ifdef GAME_DLL + g_pServerClassHead = g_classSnapshot[0]; +#else + g_pClientClassHead = g_classSnapshot[0]; +#endif + } + + IGameSystem::HotReloadPrune(g_systemSnapshot); + + const int cvarsPruned = HotReloadPruneCvarDuplicates(); + const int singletonsRestored = HotReloadRestoreSingletons(); + + g_inApply = false; + + Msg("[hotreload " HR_MODULE "] applied %s.%u: %u hooked, %u statics shared, %u copied, %u globals rebound, %u skipped, %d duplicate cvars pruned, %d singletons restored\n", + shim->module, shim->seq, counts->functions_hooked, counts->statics_shared, + counts->statics_copied, counts->globals_rebound, counts->functions_skipped, cvarsPruned, singletonsRestored); +} + +// The module is loaded either straight out of the repo (Source SDK Base 2013 MP +// with -game /game/neo) or through the steamapps/sourcemods bind mount or +// symlink (the NT;RE entry in Steam). The build-dir path relative to the module +// covers the first case and any moved repo; walking up from sourcemods/neo +// leaves the repo, so that layout falls back to the configure-time absolute +// path, which is right whenever the game runs on the machine that built it. +const char *HotReloadBuildDir() +{ + Dl_info info; + if (dladdr(reinterpret_cast(&NeoHotReload_Init), &info) && info.dli_fname) + { + static char resolved[4096]; + snprintf(resolved, sizeof(resolved), "%s", info.dli_fname); + if (char *slash = strrchr(resolved, '/')) + *slash = '\0'; + const size_t dirLen = strlen(resolved); + snprintf(resolved + dirLen, sizeof(resolved) - dirLen, "/%s", NEO_HOT_RELOAD_BUILD_DIR_REL); + struct stat st; + if (stat(resolved, &st) == 0 && S_ISDIR(st.st_mode)) + return NEO_HOT_RELOAD_BUILD_DIR_REL; + } + return NEO_HOT_RELOAD_BUILD_DIR_ABS; +} + +bool HotReloadStatus(ntre_hr_status &st) +{ + memset(&st, 0, sizeof(st)); + st.struct_size = sizeof(st); + return g_hr && ntre_hr_get_status(g_hr, &st); +} + +void HotReloadTrigger() +{ + ntre_hr_status st; + if (!HotReloadStatus(st)) + { + Msg(HR_PREFIX ": hot reload is not active in this process\n"); + return; + } + if (!st.sidecar_attached) + { + Msg(HR_PREFIX ": no sidecar attached, run make watch in your build shell\n"); + return; + } + const uint32 applied = ntre_hr_apply_pending(g_hr); + if (applied == 0) + Msg(HR_PREFIX ": nothing pending (save a source file first)\n"); +} + +void HotReloadPrintStatus() +{ + ntre_hr_status st; + if (!HotReloadStatus(st)) + { + Msg(HR_PREFIX "_status: hot reload is not active in this process\n"); + return; + } + Msg("[hotreload " HR_MODULE "] %s\n", ntre_hr_version()); + Msg(" mailbox: %s\n", ntre_hr_mailbox_dir(g_hr)); + Msg(" build id: %s\n", ntre_hr_build_id(g_hr)); + if (st.sidecar_attached) + Msg(" sidecar: attached (%s apply), seen %lld ms ago\n", + st.sidecar_auto_apply ? "auto" : "trigger", (long long)st.sidecar_seen_ms_ago); + else + Msg(" sidecar: not attached, run make watch in your build shell\n"); + Msg(" mode: %s apply; shims applied %u (last seq %u), pending %u\n", + st.auto_apply ? "auto" : "trigger", st.applied, st.applied_seq, st.pending); + Msg(" region: %s, base 0x%llx, %u slots x %llu KiB, next slot %u%s\n", + st.region_reserved ? (st.region_in_range ? "reserved in PC32 range" : "reserved OUT of range (copy mode statics)") : "NOT reserved", + (unsigned long long)st.region_base, st.slot_count, + (unsigned long long)(st.slot_size / 1024), st.next_slot, + st.region_reserved ? "" : " (shims will not share statics)"); +} + +ConCommand neo_hr_trigger(HR_PREFIX, [](const CCommand &) { HotReloadTrigger(); }, + "Apply pending hot reload shims now.", FCVAR_DONTRECORD); +ConCommand neo_hr_status(HR_PREFIX "_status", [](const CCommand &) { HotReloadPrintStatus(); }, + "Hot reload state: sidecar, mode, applied shims, reserved region.", FCVAR_DONTRECORD); + +} // namespace + +static void HotReloadAutoChanged(IConVar *, const char *, float) +{ + if (g_hr) + ntre_hr_set_auto_apply(g_hr, neo_hr_auto.GetBool()); +} + +static void HotReloadVerboseChanged(IConVar *, const char *, float) +{ + if (g_hr) + ntre_hr_set_verbose(g_hr, neo_hr_verbose.GetBool()); +} + +void NeoHotReload_Init() +{ + if (g_hr) + return; + + ntre_hr_config cfg; + ntre_hr_config_init(&cfg); + cfg.module_name = HR_MODULE; + cfg.module_anchor = reinterpret_cast(&NeoHotReload_Init); + cfg.build_dir = HotReloadBuildDir(); + cfg.auto_apply = neo_hr_auto.GetBool(); + cfg.verbose = neo_hr_verbose.GetBool(); + cfg.log = HotReloadLog; + cfg.pre_apply = HotReloadPreApply; + cfg.post_apply = HotReloadPostApply; + + g_hr = ntre_hr_init(&cfg); + if (g_hr) + Msg("[hotreload " HR_MODULE "] ready (%s); mailbox %s\n", + ntre_hr_version(), ntre_hr_mailbox_dir(g_hr)); + else + Warning("[hotreload " HR_MODULE "] init failed, continuing without hot reload (see lines above)\n"); +} + +void NeoHotReload_Frame() +{ + if (!g_hr) + return; + ntre_hr_poll(g_hr); +} + +void NeoHotReload_Shutdown() +{ + if (!g_hr) + return; + ntre_hr_shutdown(g_hr); + g_hr = nullptr; +} + +void NeoHotReload_LevelInitNotice() +{ + ntre_hr_status st; + if (HotReloadStatus(st) && !st.sidecar_attached) + Msg("[hotreload " HR_MODULE "] hot reload build, no sidecar attached; run make watch in your build shell\n"); +} + +bool NeoHotReload_InApply() +{ + return g_inApply; +} + +#endif // NEO_LINUX_HOT_RELOAD diff --git a/src/game/shared/neo/hotreload/neo_hot_reload.h b/src/game/shared/neo/hotreload/neo_hot_reload.h new file mode 100644 index 000000000..87c442056 --- /dev/null +++ b/src/game/shared/neo/hotreload/neo_hot_reload.h @@ -0,0 +1,36 @@ +// Linux hot reload: Source-side glue around the vendored loader core +// (vendor/ntre_hr.h). One instance per module; the same TU is compiled into +// server.so (sv_neo_hot_reload_*) and client.so (cl_neo_hot_reload_*). +// Everything compiles away unless NEO_LINUX_HOT_RELOAD is defined +// (the linux-debug-hotreload preset). +#ifndef NEO_HOT_RELOAD_H +#define NEO_HOT_RELOAD_H +#ifdef _WIN32 +#pragma once +#endif + +#ifdef NEO_LINUX_HOT_RELOAD + +// Create the loader context. Call once, late in module init (after ConVars are +// registered so the mode ConVars are live). Safe to call when it fails: the +// module just runs without hot reload and says why in the console. +void NeoHotReload_Init(); + +// Poll the mailbox. Call once per frame from the module's frame hook. +void NeoHotReload_Frame(); + +// Restore hooked entries and remove the state file. Call from module shutdown. +void NeoHotReload_Shutdown(); + +// One console line at level init when this is a hotreload build with no +// sidecar attached (tells the developer to run make watch). +void NeoHotReload_LevelInitNotice(); + +// True while a shim is being applied (between the pre and post apply +// callbacks). CEntityFactoryDictionary::InstallFactory uses this to keep the +// original factory when a shim's static ctors re-run LINK_ENTITY_TO_CLASS. +bool NeoHotReload_InApply(); + +#endif // NEO_LINUX_HOT_RELOAD + +#endif // NEO_HOT_RELOAD_H diff --git a/src/game/shared/neo/hotreload/vendor/VENDORED.md b/src/game/shared/neo/hotreload/vendor/VENDORED.md new file mode 100644 index 000000000..32af8ad0f --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/VENDORED.md @@ -0,0 +1,10 @@ +# Vendored loader core + +Source: ntre-build-hot-reloader at `8587f7d`, copied 2026-08-26 by scripts/vendor.sh. + +Do not edit these files here. Change the reloader repo, re-run +`scripts/vendor.sh `, review the diff, commit. + +Consumer build: add this directory to the include path and compile `src/*.cpp` +into the module with the module's own flags. Only `ntre_hr.h` is meant to be +included by consumer code. diff --git a/src/game/shared/neo/hotreload/vendor/ntre_hr.h b/src/game/shared/neo/hotreload/vendor/ntre_hr.h new file mode 100644 index 000000000..0c4c70fee --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/ntre_hr.h @@ -0,0 +1,167 @@ +/* + * ntre_hr.h + * + * Public API of the in-process hot reload loader core. + * + * C linkage, no Source SDK types: the same header drives the loader from the + * game glue (neo_hot_reload.cpp) and from the fixture host. One ntre_hr context + * per module (server.so and client.so each own one). Functions are hidden by + * default so that two modules in one process, each carrying a copy of the + * core, never clash; define NTRE_HR_API yourself to change that. + * + * Threading: every function must be called from the same thread, normally the + * one that runs the game frame. ntre_hr_poll is meant to be called once per + * frame; it rate limits itself (poll_interval_ms) and returns quickly when + * there is nothing to do. + */ +#ifndef NTRE_HR_H +#define NTRE_HR_H + +#include +#include +#include + +#ifndef NTRE_HR_API +#if defined(__GNUC__) +#define NTRE_HR_API __attribute__((visibility("hidden"))) +#else +#define NTRE_HR_API +#endif +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Bumped when this header changes incompatibly. Independent of the mailbox protocol version. */ +#define NTRE_HR_API_VERSION 2 + +/* Opaque per-module context. */ +typedef struct ntre_hr ntre_hr; + +typedef enum ntre_hr_log_level { + NTRE_HR_LOG_DEBUG = 0, /* per-symbol detail, only emitted when verbose */ + NTRE_HR_LOG_INFO = 1, + NTRE_HR_LOG_WARN = 2, + NTRE_HR_LOG_ERROR = 3 +} ntre_hr_log_level; + +/* Describes the shim being applied. Passed to the registry callbacks. */ +typedef struct ntre_hr_shim_info { + uint32_t struct_size; + const char* module; /* module name ("server") */ + uint32_t seq; /* shim sequence number */ + const char* shim_path; /* absolute path of the shim .so */ + void* handle; /* dlopen handle; NULL in pre_apply (the shim is not mapped yet) */ +} ntre_hr_shim_info; + +typedef struct ntre_hr_apply_counts { + uint32_t functions_hooked; /* original entries now jumping to shim code */ + uint32_t statics_shared; /* shim references patched to the original's statics */ + uint32_t statics_copied; /* fallback byte copies (placement out of PC32 range) */ + uint32_t functions_skipped; /* matched functions that could not be hooked safely */ + uint32_t globals_rebound; /* GOT and pointer slots re-pointed at the original's globals */ +} ntre_hr_apply_counts; + +typedef void (*ntre_hr_log_fn)(void* user, ntre_hr_log_level level, const char* message); + +/* Called before the shim is mapped (its .init_array has not run): snapshot engine registries. */ +typedef void (*ntre_hr_pre_apply_fn)(void* user, const ntre_hr_shim_info* shim); + +/* Called after hooks and relocations are in place: rebuild registries from the snapshot. */ +typedef void (*ntre_hr_post_apply_fn)(void* user, const ntre_hr_shim_info* shim, + const ntre_hr_apply_counts* counts); + +typedef struct ntre_hr_config { + uint32_t struct_size; /* set by ntre_hr_config_init */ + + /* Identity of the module this context serves. Required. Must equal the .so basename without + * extension and the CMake target name (the sidecar maps objects to modules by that name). */ + const char* module_name; + + /* Any address inside the module, for example the address of a function defined in it. + * dladdr turns it into the module path and load base. Required. */ + const void* module_anchor; + + /* CMake build dir the sidecar drives. The mailbox is /.hotreload. Absolute, or + * relative to the directory containing the module. Required. */ + const char* build_dir; + + bool auto_apply; /* apply shims as they appear (true) or wait for ntre_hr_apply_pending (false) */ + bool verbose; /* emit NTRE_HR_LOG_DEBUG messages */ + + uint32_t poll_interval_ms; /* 0 = 250 */ + uint32_t heartbeat_interval_ms; /* 0 = 1000; how often the state file is rewritten */ + + uint64_t slot_size; /* 0 = NTRE_HR_DEFAULT_SLOT_SIZE */ + uint32_t slot_count; /* 0 = NTRE_HR_DEFAULT_SLOT_COUNT */ + + ntre_hr_log_fn log; /* NULL = stderr */ + void* log_user; + + ntre_hr_pre_apply_fn pre_apply; /* optional */ + ntre_hr_post_apply_fn post_apply; /* optional */ + void* apply_user; +} ntre_hr_config; + +typedef struct ntre_hr_status { + uint32_t struct_size; + + bool sidecar_attached; /* sidecar.json present and fresh */ + int64_t sidecar_seen_ms_ago; /* -1 when never seen */ + bool sidecar_auto_apply; /* the sidecar's mode, when attached */ + + bool auto_apply; /* this context's mode */ + uint32_t pending; /* shims queued, waiting for ntre_hr_apply_pending */ + uint32_t applied; /* shims applied since init */ + uint32_t applied_seq; /* highest sequence number applied */ + + bool region_reserved; + bool region_in_range; /* whole region within PC32 reach of the module */ + uintptr_t region_base; + uint64_t slot_size; + uint32_t slot_count; + uint32_t next_slot; +} ntre_hr_status; + +/* Zero the struct and fill struct_size and defaults. Call before setting fields. */ +NTRE_HR_API void ntre_hr_config_init(ntre_hr_config* cfg); + +/* Create a context: resolve the module, read its build-id, reserve the shim region, create the + * mailbox, write the state file and queue any shims already waiting. Returns NULL on failure + * (the reason is logged). */ +NTRE_HR_API ntre_hr* ntre_hr_init(const ntre_hr_config* cfg); + +/* Call once per frame. Refreshes the heartbeat, tracks the sidecar, picks up new shims and, in + * auto mode, applies them. Returns true when at least one shim was applied during this call. */ +NTRE_HR_API bool ntre_hr_poll(ntre_hr* hr); + +/* Apply everything queued regardless of mode (the in-game trigger command). Returns the number + * of shims applied. */ +NTRE_HR_API uint32_t ntre_hr_apply_pending(ntre_hr* hr); + +NTRE_HR_API void ntre_hr_set_auto_apply(ntre_hr* hr, bool on); +NTRE_HR_API void ntre_hr_set_verbose(ntre_hr* hr, bool on); + +/* Fill a status struct. out->struct_size must be set by the caller. Returns false on bad input. */ +NTRE_HR_API bool ntre_hr_get_status(const ntre_hr* hr, ntre_hr_status* out); + +/* Absolute mailbox directory and the running module's build-id (lowercase hex). Valid until shutdown. */ +NTRE_HR_API const char* ntre_hr_mailbox_dir(const ntre_hr* hr); +NTRE_HR_API const char* ntre_hr_build_id(const ntre_hr* hr); + +/* Remove the state file, restore hooked entries, release the region and free the context. + * Shims stay mapped (their code may still be referenced) but their static destructors run here, + * so nothing of theirs is left for process exit; call it after the module has unregistered what + * shims registered (ConVars) and before the engine unloads the module. */ +NTRE_HR_API void ntre_hr_shutdown(ntre_hr* hr); + +/* "ntre_hr protocol " */ +NTRE_HR_API const char* ntre_hr_version(void); +NTRE_HR_API uint32_t ntre_hr_protocol_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* NTRE_HR_H */ diff --git a/src/game/shared/neo/hotreload/vendor/ntre_hr_protocol.h b/src/game/shared/neo/hotreload/vendor/ntre_hr_protocol.h new file mode 100644 index 000000000..5a72cbad1 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/ntre_hr_protocol.h @@ -0,0 +1,58 @@ +/* + * ntre_hr_protocol.h + * + * Single source of truth for the mailbox protocol constants shared by the + * sidecar (Rust) and the in-process loader (C++). + * + * sidecar/build.rs parses this file: every line of the form + * #define NTRE_HR_ + * #define NTRE_HR_ "" + * becomes a Rust constant. Keep values on one line, keep the forms above, and + * do not add computed macros here. Semantics live in protocol/README.md. + * + * Bump NTRE_HR_PROTOCOL_VERSION on any incompatible change to file names or + * JSON fields. Both halves reject files whose "protocol" differs from theirs. + */ +#ifndef NTRE_HR_PROTOCOL_H +#define NTRE_HR_PROTOCOL_H + +/* Protocol version carried in every mailbox file. */ +#define NTRE_HR_PROTOCOL_VERSION 1 + +/* Mailbox directory name, created inside the build dir: /.hotreload */ +#define NTRE_HR_MAILBOX_DIR ".hotreload" + +/* Sidecar presence file (sidecar to game): /sidecar.json */ +#define NTRE_HR_SIDECAR_FILE "sidecar.json" +/* Objects the sidecar has published, per module; relinked for a game process that has applied nothing yet. */ +#define NTRE_HR_UNITS_FILE "sidecar.units.json" + +/* Game state file (game to sidecar): /state..json */ +#define NTRE_HR_STATE_PREFIX "state." +#define NTRE_HR_STATE_SUFFIX ".json" + +/* Shim binary and manifest (sidecar to game): /..so and .json */ +#define NTRE_HR_SHIM_SO_SUFFIX ".so" +#define NTRE_HR_SHIM_MANIFEST_SUFFIX ".json" + +/* Apply result (game to sidecar): /..result.json */ +#define NTRE_HR_RESULT_SUFFIX ".result.json" + +/* Files are written to and renamed into place. Readers ignore this suffix. */ +#define NTRE_HR_TMP_SUFFIX ".tmp" + +/* A presence or state file whose mtime is older than this is treated as gone. */ +#define NTRE_HR_HEARTBEAT_STALE_SECONDS 5 + +/* Result status strings. */ +#define NTRE_HR_STATUS_APPLIED "applied" +#define NTRE_HR_STATUS_REJECTED "rejected" +#define NTRE_HR_STATUS_FAILED "failed" + +/* Defaults for the reserved shim region (game side, overridable in ntre_hr_config): 256 slots of 1 MB, + 256 MB of address space, one slot per typical shim, so 256 reloads per game session before the + sidecar has to link without a base. */ +#define NTRE_HR_DEFAULT_SLOT_SIZE 1048576 +#define NTRE_HR_DEFAULT_SLOT_COUNT 256 + +#endif /* NTRE_HR_PROTOCOL_H */ diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_apply.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_apply.cpp new file mode 100644 index 000000000..682e89f95 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_apply.cpp @@ -0,0 +1,269 @@ +#include "hr_apply.h" + +#include +#include + +#include + +#include "hr_got.h" +#include "hr_paths.h" +#include "hr_statics.h" +#include "ntre_hr_protocol.h" + +namespace hr { + +bool ensure_module_symbols(ntre_hr& hr, std::string& err) { + if (hr.module_symbols_loaded) return true; + if (!hr.module_file.is_open() && !hr.module_file.open(hr.module_path, err)) return false; + std::string disk_id = hr.module_file.build_id(); + if (!hr.build_id.empty() && disk_id != hr.build_id) { + err = "module on disk (" + disk_id.substr(0, 12) + ") differs from the loaded module (" + hr.build_id.substr(0, 12) + + "): it was rebuilt after launch, restart the game"; + return false; + } + if (!hr.module_file.read_symbols(".symtab", hr.module_symbols, err)) { + err += " (hot reload needs an unstripped Debug build)"; + return false; + } + hr.module_funcs.clear(); + hr.module_objects.clear(); + for (size_t i = 0; i < hr.module_symbols.size(); ++i) { + const elf::Symbol& s = hr.module_symbols[i]; + if (s.shndx == SHN_UNDEF || s.shndx >= hr.module_file.sections().size() || s.value == 0) continue; + if (s.type == STT_FUNC) hr.module_funcs[s.name].push_back(i); + else if (s.type == STT_OBJECT && s.size > 0) hr.module_objects[s.name].push_back(i); + } + hr.module_symbols_loaded = true; + hr.log.debug("module symbols: %zu total, %zu functions, %zu objects", hr.module_symbols.size(), hr.module_funcs.size(), hr.module_objects.size()); + return true; +} + +namespace { + +// Pick the module function matching a shim function: globals by name, locals by name and +// STT_FILE basename (two translation units may both define `static void helper()`). A local +// without a file match is new to its translation unit, never a hook target: the shim and the +// module were compiled by the same command, so a same-file static always carries the same file. +const elf::Symbol* match_module_function(const ntre_hr& hr, const elf::Symbol& shim_sym) { + auto it = hr.module_funcs.find(shim_sym.name); + if (it == hr.module_funcs.end()) return nullptr; + const std::vector& cands = it->second; + if (shim_sym.bind == STB_LOCAL) { + std::string want = paths::basename(shim_sym.file); + for (size_t i : cands) { + const elf::Symbol& m = hr.module_symbols[i]; + if (m.bind == STB_LOCAL && paths::basename(m.file) == want) return &m; + } + return nullptr; + } + for (size_t i : cands) { + const elf::Symbol& m = hr.module_symbols[i]; + if (m.bind != STB_LOCAL) return &m; + } + return nullptr; +} + +void hook_functions(ntre_hr& hr, const elf::File& shim, const std::vector& shim_syms, uintptr_t bias, ApplyOutcome& out) { + const std::vector& shim_secs = shim.sections(); + const std::vector& mod_secs = hr.module_file.sections(); + uint32_t long_form = 0; // functions hooked with the 13 byte jump because the shim is out of rel32 reach + for (const elf::Symbol& s : shim_syms) { + if (s.type != STT_FUNC || s.shndx == SHN_UNDEF || s.shndx >= shim_secs.size() || s.value == 0) continue; + if (!(shim_secs[s.shndx].flags & SHF_EXECINSTR)) continue; + if (elf::is_crt_symbol(s.name) || elf::is_std_symbol(s.name)) { + hr.log.debug("hook: skip library symbol %s", s.name.c_str()); + continue; + } + const elf::Symbol* m = match_module_function(hr, s); + if (!m) { + hr.log.debug("hook: %s is new (no original)", s.name.c_str()); + continue; + } + uintptr_t original = hr.module_base + m->value; + uintptr_t target = bias + s.value; + if (m->shndx >= mod_secs.size()) { out.counts.functions_skipped++; continue; } + const elf::Section& msec = mod_secs[m->shndx]; + const size_t need = hook::short_reaches(original, target) ? hook::kShortSize : hook::kPatchSize; + if (m->value + need > msec.addr + msec.size) { + out.counts.functions_skipped++; + out.warnings.push_back("skipped " + s.name + ": fewer than " + std::to_string(need) + " bytes before the end of " + msec.name); + continue; + } + // The entry as the module file has it: what the saved copy must hold, and the reference + // that tells a debugger's int3 (0xCC) apart from the code's own bytes. + const uint8_t* pristine = nullptr; + if (msec.type == SHT_PROGBITS && msec.offset + (m->value - msec.addr) + hook::kPatchSize <= hr.module_file.size()) + pristine = hr.module_file.data() + msec.offset + (m->value - msec.addr); + auto it = hr.hooks.find(original); + hook::Patch fresh; + hook::Patch& p = it != hr.hooks.end() ? it->second : fresh; + hook::Report r = hook::install(original, target, p, pristine); + switch (r.outcome) { + case hook::Outcome::Installed: + case hook::Outcome::Repointed: + out.counts.functions_hooked++; + if (r.form == hook::Form::Long) long_form++; + hr.log.debug("hook: %s 0x%lx -> 0x%lx (%s form)", s.name.c_str(), static_cast(original), static_cast(target), + r.form == hook::Form::Short ? "5 byte" : "13 byte"); + break; + case hook::Outcome::RepointedAroundInt3: + out.counts.functions_hooked++; + if (r.form == hook::Form::Long) long_form++; + out.warnings.push_back(s.name + ": debugger breakpoint (int3 0xCC) at entry offset " + hook::offsets(r.int3_mask) + " kept; the jump was re-pointed around it"); + break; + case hook::Outcome::RepointedOverInt3: + out.counts.functions_hooked++; + if (r.form == hook::Form::Long) long_form++; + out.warnings.push_back(s.name + ": debugger breakpoint (int3 0xCC) at entry offset " + hook::offsets(r.int3_mask) + + " sat on bytes the hook rewrites and was displaced; delete that breakpoint now (the debugger will corrupt the jump when it restores its byte) and set it in the shim instead"); + break; + case hook::Outcome::SkippedInt3: + out.counts.functions_skipped++; + out.warnings.push_back("skipped " + s.name + ": debugger breakpoint (int3 0xCC) at entry offset " + hook::offsets(r.int3_mask) + "; remove it and save again"); + break; + case hook::Outcome::SkippedForeign: + out.counts.functions_skipped++; + out.warnings.push_back("skipped " + s.name + ": entry bytes differ from the module file at offset " + hook::offsets(r.foreign_mask) + " (patched by another tool?)"); + break; + case hook::Outcome::Failed: + out.counts.functions_skipped++; + out.warnings.push_back("skipped " + s.name + ": " + r.err); + break; + } + if (p.active && it == hr.hooks.end()) hr.hooks[original] = p; + } + if (long_form) + out.warnings.push_back(std::to_string(long_form) + " function(s) hooked with the 13 byte jump because the shim is more than 2 GB from the module: a breakpoint placed after their prologue in the module would corrupt the jump, set such breakpoints in the shim"); +} + +// Everything after the shim is mapped: read its symbols, hook, share statics, rebind globals. +// Returns false with out.error set; the counts still reflect what was done before the failure. +bool apply_mapped(ntre_hr& hr, const Manifest& m, uintptr_t bias, ApplyOutcome& out) { + std::string err; + elf::File shim; + if (!shim.open(m.shim_path, err)) { out.error = err; return false; } + uint64_t slo = 0, shi = 0; + shim.load_extent(slo, shi); + uintptr_t shim_lo = bias + slo, shim_hi = bias + shi; + bool in_range = pc32_reachable(shim_lo, shim_hi, hr.module_lo, hr.module_hi); + if (!in_range) out.warnings.push_back("shim at " + hex_address(shim_lo) + " is out of PC32 range of the module; statics will be copied, restart for exact state"); + hr.log.debug("shim %s mapped at 0x%lx..0x%lx (bias 0x%lx, %s)", m.shim.c_str(), static_cast(shim_lo), + static_cast(shim_hi), static_cast(bias), in_range ? "in range" : "out of range"); + + std::vector shim_syms; + if (!shim.read_symbols(".symtab", shim_syms, err)) { out.error = "shim has no .symtab: " + err; return false; } + // The handle its static destructors were registered under; shutdown finalizes them with it. + for (const elf::Symbol& s : shim_syms) { + if (s.name == "__dso_handle" && s.shndx != SHN_UNDEF && !hr.shim_dso_handles.empty()) { + hr.shim_dso_handles.back() = bias + s.value; + break; + } + } + + hook_functions(hr, shim, shim_syms, bias, out); + + StaticShareInput si; + si.shim = &shim; + si.shim_bias = bias; + si.shim_symbols = &shim_syms; + si.module_base = hr.module_base; + si.module_symbols = &hr.module_symbols; + si.module_objects = &hr.module_objects; + si.in_pc32_range = in_range; + StaticShareOutcome so; + share_statics(si, so, hr.log); + out.counts.statics_shared += so.shared; + out.counts.statics_copied += so.copied; + out.warnings.insert(out.warnings.end(), so.warnings.begin(), so.warnings.end()); + + GotRebindInput gi; + gi.shim = &shim; + gi.shim_bias = bias; + gi.module_base = hr.module_base; + gi.module_symbols = &hr.module_symbols; + gi.module_objects = &hr.module_objects; + gi.module_funcs = &hr.module_funcs; + gi.hooks = &hr.hooks; + GotRebindOutcome go; + rebind_globals(gi, go, hr.log); + out.counts.globals_rebound += go.rebound; + out.warnings.insert(out.warnings.end(), go.warnings.begin(), go.warnings.end()); + return true; +} + +} // namespace + +bool apply_shim(ntre_hr& hr, const Manifest& m, ApplyOutcome& out) { + out = ApplyOutcome(); + out.status = NTRE_HR_STATUS_FAILED; + std::string err; + + if (!ensure_module_symbols(hr, err)) { out.error = err; return false; } + if (hr.module_file.changed_on_disk()) { + out.error = "the module on disk changed since launch (rebuilt in place): restart the game"; + return false; + } + if (!paths::exists(m.shim_path)) { out.error = "shim file missing: " + m.shim_path; return false; } + if (m.has_module_base && m.module_base != hr.module_base) { + out.error = "shim binds the module at " + hex_address(m.module_base) + " but this process has it at " + hex_address(hr.module_base) + ": save again"; + return false; + } + + ntre_hr_shim_info info; + memset(&info, 0, sizeof info); + info.struct_size = sizeof info; + info.module = hr.module_name.c_str(); + info.seq = m.seq; + info.shim_path = m.shim_path.c_str(); + info.handle = nullptr; + if (hr.pre_apply) hr.pre_apply(hr.apply_user, &info); + + // Free the advertised slots so ld.so's mmap hint (the linked base) lands there. + bool released = false; + if (m.has_slot && m.has_link_base && hr.region.reserved) { + if (m.link_base != hr.region.slot_addr(m.slot) || m.slot + m.slots > hr.region.slot_count) { + out.warnings.push_back("manifest link base " + hex_address(m.link_base) + " does not match this process's region; placement is up to the kernel"); + } else if (!hr.region.slots_free(m.slot, m.slots)) { + out.warnings.push_back("region slot " + std::to_string(m.slot) + " is not free in this process (used by an earlier shim, or lost); placement is up to the kernel"); + } else { + released = hr.region.release_slots(m.slot, m.slots); + if (!released) out.warnings.push_back("could not release region slots for the shim; placement is up to the kernel"); + } + } + + void* h = dlopen(m.shim_path.c_str(), RTLD_NOW | RTLD_LOCAL); + if (!h) { + const char* e = dlerror(); + out.error = std::string("dlopen failed: ") + (e ? e : "unknown error"); + if (released && !hr.region.reclaim_slots(m.slot, m.slots)) + out.warnings.push_back("could not reclaim the released region slots; they are lost for this process"); + return false; + } + hr.shim_handles.push_back(h); + hr.shim_dso_handles.push_back(0); + info.handle = h; + + struct link_map* lm = nullptr; + uintptr_t bias = 0; + if (dlinfo(h, RTLD_DI_LINKMAP, &lm) == 0 && lm) bias = static_cast(lm->l_addr); + + // Slot bookkeeping first, so the region state is right whatever happens below. + if (m.has_link_base) { + if (bias == 0) { + if (released) hr.region.occupy_slots(m.slot, m.slots); + } else { + out.warnings.push_back("shim landed at " + hex_address(m.link_base + bias) + " instead of " + hex_address(m.link_base) + " (mmap hint refused)"); + if (released && !hr.region.reclaim_slots(m.slot, m.slots)) + out.warnings.push_back("could not reclaim the released region slots; they are lost for this process"); + } + } + + // The shim is mapped and its constructors have run: post_apply must run whatever happens next, + // so the glue can rebuild its registries even when a later step fails. + bool ok = apply_mapped(hr, m, bias, out); + if (hr.post_apply) hr.post_apply(hr.apply_user, &info, &out.counts); + out.status = ok ? NTRE_HR_STATUS_APPLIED : NTRE_HR_STATUS_FAILED; + return ok; +} + +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_apply.h b/src/game/shared/neo/hotreload/vendor/src/hr_apply.h new file mode 100644 index 000000000..1282cdc89 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_apply.h @@ -0,0 +1,28 @@ +// The apply pipeline for one shim: map, read symbols, hook, +// share statics, rebind globals, registry callbacks. +#ifndef NTRE_HR_APPLY_H +#define NTRE_HR_APPLY_H + +#include +#include + +#include "hr_context.h" + +namespace hr { + +struct ApplyOutcome { + const char* status = "failed"; // NTRE_HR_STATUS_* from the protocol header + ntre_hr_apply_counts counts = {}; + std::vector warnings; + std::string error; +}; + +// Open the module on disk, verify it is the loaded build and index its functions. Idempotent. +bool ensure_module_symbols(ntre_hr& hr, std::string& err); + +// Apply one manifest. Returns true when the status is "applied". +bool apply_shim(ntre_hr& hr, const Manifest& m, ApplyOutcome& out); + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_context.h b/src/game/shared/neo/hotreload/vendor/src/hr_context.h new file mode 100644 index 000000000..ccc6d2130 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_context.h @@ -0,0 +1,65 @@ +// The per-module context behind the opaque ntre_hr handle. +#ifndef NTRE_HR_CONTEXT_H +#define NTRE_HR_CONTEXT_H + +#include +#include +#include +#include +#include + +#include "hr_elf.h" +#include "hr_hook.h" +#include "hr_log.h" +#include "hr_mailbox.h" +#include "hr_region.h" +#include "ntre_hr.h" + +struct ntre_hr { + // Configuration (copied out of ntre_hr_config). + std::string module_name; + const void* module_anchor = nullptr; + hr::Logger log; + ntre_hr_pre_apply_fn pre_apply = nullptr; + ntre_hr_post_apply_fn post_apply = nullptr; + void* apply_user = nullptr; + bool auto_apply = true; + uint32_t poll_interval_ms = 250; + uint32_t heartbeat_interval_ms = 1000; + + // Module identity. + std::string module_path; // absolute + std::string module_dir; + uintptr_t module_base = 0; // load bias + uintptr_t module_lo = 0; // mapped extent + uintptr_t module_hi = 0; + std::string build_id; // build-id of the loaded image + hr::elf::File module_file; // opened on first apply + std::vector module_symbols; + std::map> module_funcs; // name -> indexes into module_symbols (STT_FUNC) + std::map> module_objects; // name -> indexes into module_symbols (STT_OBJECT, defined, sized) + bool module_symbols_loaded = false; + + hr::Region region; + hr::Mailbox mailbox; + + // Runtime state. + std::map hooks; // original entry -> patch + std::deque pending; + std::vector shim_handles; + std::vector shim_dso_handles; // __dso_handle of each shim, 0 when unknown; finalized at shutdown + std::map seen_manifests; // seq -> mtime of the manifest already handled; seqs restart per game process + uint32_t applied_seq = 0; + uint32_t applied_count = 0; + uint64_t heartbeat = 0; + int64_t last_poll_ms = -1; + int64_t last_heartbeat_ms = -1; + hr::SidecarPresence sidecar; + bool sidecar_attached = false; + bool sidecar_ever_seen = false; + bool foreign_noted = false; // told the developer once about shims linked for another process + std::string sidecar_session; // id of the sidecar session last seen attached; manifests of other sessions are ignored + bool other_session_noted = false; +}; + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_elf.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_elf.cpp new file mode 100644 index 000000000..d427aad28 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_elf.cpp @@ -0,0 +1,306 @@ +#include "hr_elf.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace hr { +namespace elf { + +File::~File() { close(); } + +void File::close() { + if (map_) { ::munmap(map_, len_); map_ = nullptr; } + if (fd_ >= 0) { ::close(fd_); fd_ = -1; } + len_ = 0; + sections_.clear(); +} + +bool File::open(const std::string& path, std::string& err) { + close(); + path_ = path; + fd_ = ::open(path.c_str(), O_RDONLY | O_CLOEXEC); + if (fd_ < 0) { err = path + ": " + strerror(errno); return false; } + struct stat st; + if (::fstat(fd_, &st) != 0 || st.st_size < static_cast(sizeof(Elf64_Ehdr))) { + err = path + ": not an ELF file (too small)"; + close(); + return false; + } + len_ = static_cast(st.st_size); + ino_ = static_cast(st.st_ino); + mtime_ns_ = static_cast(st.st_mtim.tv_sec) * 1000000000 + st.st_mtim.tv_nsec; + void* m = ::mmap(nullptr, len_, PROT_READ, MAP_PRIVATE, fd_, 0); + if (m == MAP_FAILED) { err = path + ": mmap: " + strerror(errno); map_ = nullptr; close(); return false; } + map_ = static_cast(m); + if (!parse(err)) { close(); return false; } + return true; +} + +bool File::changed_on_disk() const { + if (!map_) return false; + struct stat st; + if (::stat(path_.c_str(), &st) != 0) return true; + int64_t mtime_ns = static_cast(st.st_mtim.tv_sec) * 1000000000 + st.st_mtim.tv_nsec; + return static_cast(st.st_ino) != ino_ || static_cast(st.st_size) != len_ || mtime_ns != mtime_ns_; +} + +bool File::parse(std::string& err) { + const Elf64_Ehdr* eh = header(); + if (memcmp(eh->e_ident, ELFMAG, SELFMAG) != 0 || eh->e_ident[EI_CLASS] != ELFCLASS64 || + eh->e_ident[EI_DATA] != ELFDATA2LSB || eh->e_machine != EM_X86_64) { + err = path_ + ": not a little endian x86-64 ELF64 file"; + return false; + } + if (eh->e_shoff == 0 || eh->e_shentsize != sizeof(Elf64_Shdr)) { + // No section table (stripped beyond use). Keep the file open for program headers. + return true; + } + if (!in_bounds(eh->e_shoff, static_cast(eh->e_shnum) * sizeof(Elf64_Shdr))) { + err = path_ + ": section table out of bounds"; + return false; + } + const Elf64_Shdr* sh = reinterpret_cast(map_ + eh->e_shoff); + const char* shstr = nullptr; + uint64_t shstr_len = 0; + if (eh->e_shstrndx < eh->e_shnum && in_bounds(sh[eh->e_shstrndx].sh_offset, sh[eh->e_shstrndx].sh_size)) { + shstr = reinterpret_cast(map_ + sh[eh->e_shstrndx].sh_offset); + shstr_len = sh[eh->e_shstrndx].sh_size; + } + sections_.resize(eh->e_shnum); + for (uint32_t i = 0; i < eh->e_shnum; ++i) { + Section& s = sections_[i]; + if (shstr && sh[i].sh_name < shstr_len) s.name = std::string(shstr + sh[i].sh_name, strnlen(shstr + sh[i].sh_name, shstr_len - sh[i].sh_name)); + s.type = sh[i].sh_type; + s.flags = sh[i].sh_flags; + s.addr = sh[i].sh_addr; + s.offset = sh[i].sh_offset; + s.size = sh[i].sh_size; + s.link = sh[i].sh_link; + s.info = sh[i].sh_info; + s.addralign = sh[i].sh_addralign; + s.entsize = sh[i].sh_entsize; + } + return true; +} + +const Section* File::find_section(const char* name) const { + int i = find_section_index(name); + return i < 0 ? nullptr : §ions_[static_cast(i)]; +} + +int File::find_section_index(const char* name) const { + for (size_t i = 0; i < sections_.size(); ++i) + if (sections_[i].name == name) return static_cast(i); + return -1; +} + +bool File::read_symbols(const char* table, std::vector& out, std::string& err) const { + out.clear(); + const Section* sym = find_section(table); + if (!sym) { err = path_ + ": no " + table + " section"; return false; } + if (sym->type != SHT_SYMTAB && sym->type != SHT_DYNSYM) { err = path_ + ": " + table + " is not a symbol table"; return false; } + if (sym->link >= sections_.size()) { err = path_ + ": bad symtab link"; return false; } + const Section& str = sections_[sym->link]; + if (!in_bounds(sym->offset, sym->size) || !in_bounds(str.offset, str.size)) { err = path_ + ": symtab out of bounds"; return false; } + const Elf64_Sym* syms = reinterpret_cast(map_ + sym->offset); + size_t count = sym->size / sizeof(Elf64_Sym); + const char* strs = reinterpret_cast(map_ + str.offset); + out.reserve(count); + std::string current_file; + for (size_t i = 0; i < count; ++i) { + const Elf64_Sym& s = syms[i]; + Symbol o; + o.index = static_cast(i); + o.type = ELF64_ST_TYPE(s.st_info); + o.bind = ELF64_ST_BIND(s.st_info); + o.value = s.st_value; + o.size = s.st_size; + o.shndx = s.st_shndx; + if (s.st_name < str.size) o.name = std::string(strs + s.st_name, strnlen(strs + s.st_name, str.size - s.st_name)); + if (o.type == STT_FILE) { + current_file = o.name; + } else if (o.bind == STB_LOCAL) { + o.file = current_file; + } else { + // Globals follow all locals in a valid symtab; scoping no longer applies. + current_file.clear(); + } + if (o.type == STT_SECTION && o.name.empty() && o.shndx < sections_.size()) o.name = sections_[o.shndx].name; + out.push_back(std::move(o)); + } + return true; +} + +bool File::read_rela(const Section& sec, std::vector& out, std::string& err) const { + out.clear(); + if (sec.type != SHT_RELA) { err = path_ + ": " + sec.name + " is not a RELA section"; return false; } + if (!in_bounds(sec.offset, sec.size)) { err = path_ + ": rela out of bounds"; return false; } + const Elf64_Rela* r = reinterpret_cast(map_ + sec.offset); + size_t count = sec.size / sizeof(Elf64_Rela); + out.reserve(count); + for (size_t i = 0; i < count; ++i) { + Rela o; + o.offset = r[i].r_offset; + o.sym = ELF64_R_SYM(r[i].r_info); + o.type = ELF64_R_TYPE(r[i].r_info); + o.addend = r[i].r_addend; + out.push_back(o); + } + return true; +} + +std::string to_hex(const uint8_t* p, size_t n) { + static const char* digits = "0123456789abcdef"; + std::string s; + s.reserve(n * 2); + for (size_t i = 0; i < n; ++i) { s.push_back(digits[p[i] >> 4]); s.push_back(digits[p[i] & 15]); } + return s; +} + +std::string find_build_id_in_notes(const uint8_t* p, size_t len, size_t align) { + if (align < 4) align = 4; + size_t off = 0; + while (off + sizeof(Elf64_Nhdr) <= len) { + const Elf64_Nhdr* nh = reinterpret_cast(p + off); + size_t name_off = off + sizeof(Elf64_Nhdr); + size_t desc_off = name_off + ((nh->n_namesz + align - 1) & ~(align - 1)); + size_t next = desc_off + ((nh->n_descsz + align - 1) & ~(align - 1)); + if (desc_off > len || next > len) break; + if (nh->n_type == NT_GNU_BUILD_ID && nh->n_namesz == 4 && memcmp(p + name_off, "GNU", 4) == 0 && nh->n_descsz > 0) + return to_hex(p + desc_off, nh->n_descsz); + off = next; + } + return std::string(); +} + +std::string File::build_id() const { + if (!map_) return std::string(); + for (const Section& s : sections_) { + if (s.type != SHT_NOTE || !in_bounds(s.offset, s.size)) continue; + std::string id = find_build_id_in_notes(map_ + s.offset, s.size, s.addralign); + if (!id.empty()) return id; + } + const Elf64_Ehdr* eh = header(); + if (eh->e_phoff && eh->e_phentsize == sizeof(Elf64_Phdr) && in_bounds(eh->e_phoff, static_cast(eh->e_phnum) * sizeof(Elf64_Phdr))) { + const Elf64_Phdr* ph = reinterpret_cast(map_ + eh->e_phoff); + for (uint32_t i = 0; i < eh->e_phnum; ++i) { + if (ph[i].p_type != PT_NOTE || !in_bounds(ph[i].p_offset, ph[i].p_filesz)) continue; + std::string id = find_build_id_in_notes(map_ + ph[i].p_offset, ph[i].p_filesz, ph[i].p_align); + if (!id.empty()) return id; + } + } + return std::string(); +} + +bool File::load_extent(uint64_t& lo, uint64_t& hi) const { + if (!map_) return false; + const Elf64_Ehdr* eh = header(); + if (!eh->e_phoff || eh->e_phentsize != sizeof(Elf64_Phdr) || !in_bounds(eh->e_phoff, static_cast(eh->e_phnum) * sizeof(Elf64_Phdr))) return false; + const Elf64_Phdr* ph = reinterpret_cast(map_ + eh->e_phoff); + bool any = false; + lo = UINT64_MAX; + hi = 0; + for (uint32_t i = 0; i < eh->e_phnum; ++i) { + if (ph[i].p_type != PT_LOAD) continue; + any = true; + if (ph[i].p_vaddr < lo) lo = ph[i].p_vaddr; + if (ph[i].p_vaddr + ph[i].p_memsz > hi) hi = ph[i].p_vaddr + ph[i].p_memsz; + } + return any; +} + +bool File::relro_extent(uint64_t& lo, uint64_t& hi) const { + if (!map_) return false; + const Elf64_Ehdr* eh = header(); + if (!eh->e_phoff || eh->e_phentsize != sizeof(Elf64_Phdr) || !in_bounds(eh->e_phoff, static_cast(eh->e_phnum) * sizeof(Elf64_Phdr))) return false; + const Elf64_Phdr* ph = reinterpret_cast(map_ + eh->e_phoff); + for (uint32_t i = 0; i < eh->e_phnum; ++i) { + if (ph[i].p_type != PT_GNU_RELRO) continue; + lo = ph[i].p_vaddr; + hi = ph[i].p_vaddr + ph[i].p_memsz; + return true; + } + return false; +} + +namespace { + +struct ImageQuery { + uintptr_t addr; + LoadedImage* out; + bool found; +}; + +int image_callback(struct dl_phdr_info* info, size_t, void* data) { + ImageQuery* q = static_cast(data); + uintptr_t lo = UINTPTR_MAX, hi = 0; + bool contains = false; + for (int i = 0; i < info->dlpi_phnum; ++i) { + const ElfW(Phdr)& ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_LOAD) continue; + uintptr_t s = info->dlpi_addr + ph.p_vaddr; + uintptr_t e = s + ph.p_memsz; + if (s < lo) lo = s; + if (e > hi) hi = e; + if (q->addr >= s && q->addr < e) contains = true; + } + if (!contains) return 0; + std::string build_id; + for (int i = 0; i < info->dlpi_phnum && build_id.empty(); ++i) { + const ElfW(Phdr)& ph = info->dlpi_phdr[i]; + if (ph.p_type != PT_NOTE) continue; + const uint8_t* p = reinterpret_cast(info->dlpi_addr + ph.p_vaddr); + build_id = find_build_id_in_notes(p, ph.p_memsz, ph.p_align); + } + q->out->bias = info->dlpi_addr; + q->out->lo = lo; + q->out->hi = hi; + q->out->build_id = build_id; + q->out->name = info->dlpi_name ? info->dlpi_name : ""; + q->found = true; + return 1; +} + +} // namespace + +bool find_loaded_image(uintptr_t addr, LoadedImage& out) { + ImageQuery q{addr, &out, false}; + dl_iterate_phdr(image_callback, &q); + return q.found; +} + +bool is_std_symbol(const std::string& n) { + static const char* const prefixes[] = { + "_ZSt", "_ZNSt", "_ZNKSt", "_ZNVSt", "_ZNKVSt", "_ZNSa", "_ZNKSa", "_ZNSs", "_ZNKSs", + "_ZNSb", "_ZNKSb", "_ZNSi", "_ZNSo", "_ZNSd", "_ZZNSt", "_ZZSt", "_ZTSSt", "_ZTVSt", "_ZTISt", + "_ZGVZNSt", "_ZGVZSt", "_ZThn", "_ZTv", + }; + for (const char* p : prefixes) + if (n.compare(0, strlen(p), p) == 0) return true; + return n.compare(0, 6, "__cxa_") == 0 || n.compare(0, 6, "__gxx_") == 0 || n.compare(0, 9, "__gnu_cxx") == 0; +} + +bool is_crt_symbol(const std::string& n) { + static const char* const names[] = { + "_init", "_fini", "_start", "deregister_tm_clones", "register_tm_clones", + "__do_global_dtors_aux", "frame_dummy", "__do_global_ctors_aux", "__libc_csu_init", + "__libc_csu_fini", "_dl_relocate_static_pie", "__stack_chk_fail_local", + }; + for (const char* p : names) + if (n == p) return true; + // Per-TU static init and teardown glue is present in both shim and module; hooking it is noise. + static const char* const prefixes[] = {"_GLOBAL__sub_I_", "_GLOBAL__sub_D_", "__tcf_"}; + for (const char* p : prefixes) + if (n.compare(0, strlen(p), p) == 0) return true; + // Mangled as _Z41__static_initialization_and_destruction_0ii, so match anywhere. + return n.find("__static_initialization_and_destruction_") != std::string::npos; +} + +} // namespace elf +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_elf.h b/src/game/shared/neo/hotreload/vendor/src/hr_elf.h new file mode 100644 index 000000000..970d20b5c --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_elf.h @@ -0,0 +1,121 @@ +// ELF64 reader for the pieces the loader needs: section table, .symtab with +// STT_FILE scoping, RELA sections, GNU build-id, PT_LOAD extent. Reads files +// through mmap and never copies section data. Also helpers that inspect the +// images already loaded in this process through dl_iterate_phdr. +// +// Mined from jet-live's ElfProgramInfoLoader but +// reimplemented: no ELFIO, no exceptions. +#ifndef NTRE_HR_ELF_H +#define NTRE_HR_ELF_H + +#include + +#include +#include +#include + +namespace hr { +namespace elf { + +struct Section { + std::string name; + uint32_t type = 0; + uint64_t flags = 0; + uint64_t addr = 0; + uint64_t offset = 0; + uint64_t size = 0; + uint32_t link = 0; + uint32_t info = 0; + uint64_t addralign = 0; + uint64_t entsize = 0; +}; + +struct Symbol { + std::string name; + std::string file; // preceding STT_FILE name for local symbols, empty for globals + uint64_t value = 0; + uint64_t size = 0; + uint8_t type = 0; // STT_* + uint8_t bind = 0; // STB_* + uint16_t shndx = 0; + uint32_t index = 0; // index in the symbol table +}; + +struct Rela { + uint64_t offset = 0; + uint32_t sym = 0; + uint32_t type = 0; // R_X86_64_* + int64_t addend = 0; +}; + +class File { +public: + File() = default; + ~File(); + File(const File&) = delete; + File& operator=(const File&) = delete; + + bool open(const std::string& path, std::string& err); + void close(); + bool is_open() const { return map_ != nullptr; } + // The file at path() is no longer the one that was opened (inode, size or mtime differ), for + // example because it was rebuilt in place. The mapping still shows the bytes it had at open. + bool changed_on_disk() const; + const std::string& path() const { return path_; } + const uint8_t* data() const { return map_; } + size_t size() const { return len_; } + const Elf64_Ehdr* header() const { return reinterpret_cast(map_); } + + const std::vector
& sections() const { return sections_; } + const Section* find_section(const char* name) const; + int find_section_index(const char* name) const; + + // Symbols of ".symtab" or ".dynsym". STT_SECTION symbols get the section name as `name`. + bool read_symbols(const char* table, std::vector& out, std::string& err) const; + bool read_rela(const Section& sec, std::vector& out, std::string& err) const; + + // Lowercase hex, empty when the file has no GNU build-id note. + std::string build_id() const; + // Lowest and highest virtual address covered by PT_LOAD segments. + bool load_extent(uint64_t& lo, uint64_t& hi) const; + // The PT_GNU_RELRO range (read-only after relocation), false when there is none. + bool relro_extent(uint64_t& lo, uint64_t& hi) const; + +private: + bool parse(std::string& err); + bool in_bounds(uint64_t off, uint64_t len) const { return off <= len_ && len <= len_ - off; } + + int fd_ = -1; + uint8_t* map_ = nullptr; + size_t len_ = 0; + std::string path_; + std::vector
sections_; + // Identity of the file at open, for changed_on_disk(). + uint64_t ino_ = 0; + int64_t mtime_ns_ = 0; +}; + +// Parse a run of ELF notes and return the GNU build-id as hex, or "". +std::string find_build_id_in_notes(const uint8_t* p, size_t len, size_t align); + +// The image (shared object or executable) whose PT_LOAD segments contain `addr`. +struct LoadedImage { + uintptr_t bias = 0; // dlpi_addr: add to st_value / p_vaddr for runtime addresses + uintptr_t lo = 0; // mapped extent + uintptr_t hi = 0; + std::string build_id; // lowercase hex, "" when absent + std::string name; // dlpi_name as reported by ld.so (empty for the main program) +}; +bool find_loaded_image(uintptr_t addr, LoadedImage& out); + +// True for mangled names in namespace std and friends (library code present in both shim and module). +bool is_std_symbol(const std::string& name); +// True for crt glue every DSO carries (_init, frame_dummy, ...) and per-TU static init glue (_GLOBAL__sub_I_*). +bool is_crt_symbol(const std::string& name); + +std::string to_hex(const uint8_t* p, size_t n); + +} // namespace elf +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_got.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_got.cpp new file mode 100644 index 000000000..bcdbe7974 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_got.cpp @@ -0,0 +1,110 @@ +#include "hr_got.h" + +#include +#include +#include + +#include +#include +#include + +namespace hr { + +namespace { + +const elf::Symbol* module_symbol(const std::map>& index, const std::vector& syms, const std::string& name) { + auto it = index.find(name); + if (it == index.end()) return nullptr; + for (size_t i : it->second) { + const elf::Symbol& m = syms[i]; + if (m.bind != STB_LOCAL) return &m; // dynamic symbols are global or weak; so is the module's counterpart + } + return nullptr; +} + +// Store a pointer into a slot. Slots in the shim's RELRO range (.got, .data.rel.ro) are read-only +// since dlopen finished relocating; open the page for the write and close it again. +bool write_slot(uintptr_t addr, uint64_t value, bool relro, std::string& err) { + if (!relro) { + memcpy(reinterpret_cast(addr), &value, sizeof value); + return true; + } + long ps = sysconf(_SC_PAGESIZE); + uintptr_t page = addr & ~static_cast(ps - 1); + size_t span = (addr + sizeof value) - page; + span = (span + static_cast(ps) - 1) & ~static_cast(ps - 1); + if (mprotect(reinterpret_cast(page), span, PROT_READ | PROT_WRITE) != 0) { err = std::string("mprotect(rw): ") + strerror(errno); return false; } + memcpy(reinterpret_cast(addr), &value, sizeof value); + if (mprotect(reinterpret_cast(page), span, PROT_READ) != 0) { err = std::string("mprotect(r): ") + strerror(errno); return false; } + return true; +} + +} // namespace + +bool rebind_globals(const GotRebindInput& in, GotRebindOutcome& out, const Logger& log) { + out = GotRebindOutcome(); + const elf::File& shim = *in.shim; + const std::vector& secs = shim.sections(); + std::string err; + + int dynsym_index = shim.find_section_index(".dynsym"); + std::vector dyn; + if (dynsym_index < 0 || !shim.read_symbols(".dynsym", dyn, err)) { + out.warnings.push_back("shim has no readable .dynsym, globals stay private copies" + (err.empty() ? std::string() : ": " + err)); + return true; + } + uint64_t relro_lo = 0, relro_hi = 0; + const bool has_relro = shim.relro_extent(relro_lo, relro_hi); + + std::set objects, functions, fresh, warned; + uint32_t slots = 0, unhooked = 0; + for (const elf::Section& s : secs) { + if (s.type != SHT_RELA || static_cast(s.link) != dynsym_index) continue; // .rela.dyn (and .rela.plt, whose JUMP_SLOTs are skipped below) + std::vector rel; + if (!shim.read_rela(s, rel, err)) { out.warnings.push_back(err); continue; } + for (const elf::Rela& r : rel) { + if (r.type != R_X86_64_GLOB_DAT && r.type != R_X86_64_64) continue; + if (r.sym == 0 || r.sym >= dyn.size()) continue; + const elf::Symbol& ds = dyn[r.sym]; + // Only the shim's own definitions: undefined ones were already bound to the module or a library. + if (ds.shndx == SHN_UNDEF || ds.shndx == SHN_ABS || ds.name.empty()) continue; + uintptr_t value = 0; + if (ds.type == STT_OBJECT) { + const elf::Symbol* m = module_symbol(*in.module_objects, *in.module_symbols, ds.name); + if (!m) { fresh.insert(ds.name); continue; } // new object: stays shim-owned + if (m->size != ds.size) { + if (warned.insert(ds.name).second) + out.warnings.push_back(ds.name + ": size differs between the module (" + std::to_string(m->size) + ") and the shim (" + + std::to_string(ds.size) + "), not rebound; its type changed, restart for exact state"); + continue; + } + value = in.module_base + m->value; + objects.insert(ds.name); + } else if (ds.type == STT_FUNC) { + // A pointer to a function the module also has: point it at the module's entry while that + // entry is hooked, so the pointer follows later reloads. Otherwise it stays on the shim's copy. + const elf::Symbol* m = module_symbol(*in.module_funcs, *in.module_symbols, ds.name); + if (!m) { fresh.insert(ds.name); continue; } + uintptr_t entry = in.module_base + m->value; + auto h = in.hooks->find(entry); + if (h == in.hooks->end() || !h->second.active) { unhooked++; continue; } + value = entry; + functions.insert(ds.name); + } else { + continue; // TLS, ifuncs, section symbols: not ours to rebind + } + if (r.type == R_X86_64_64) value += static_cast(r.addend); + const uintptr_t slot = in.shim_bias + r.offset; + const bool relro = has_relro && r.offset >= relro_lo && r.offset < relro_hi; + if (!write_slot(slot, value, relro, err)) { out.warnings.push_back(ds.name + ": " + err); continue; } + slots++; + log.debug("got: %s slot 0x%lx -> 0x%lx%s", ds.name.c_str(), static_cast(slot), static_cast(value), relro ? " (relro)" : ""); + } + } + out.rebound = static_cast(objects.size() + functions.size()); + log.debug("got: %zu object(s) and %zu function(s) rebound in %u slot(s), %zu new, %u function pointer(s) left on unhooked entries", + objects.size(), functions.size(), slots, fresh.size(), unhooked); + return true; +} + +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_got.h b/src/game/shared/neo/hotreload/vendor/src/hr_got.h new file mode 100644 index 000000000..1f46e780a --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_got.h @@ -0,0 +1,44 @@ +// Global rebinding. Everything with external linkage that new +// code mentions (globals, class statics, ConVar objects, vtables, typeinfo, +// ServerClass/ClientClass objects) is reached through the shim's GOT or through +// absolute pointer slots in its data, and ld.so bound those slots to the shim's +// own copies: under RTLD_LOCAL the shim precedes its module in its own lookup +// scope. For every such object that also exists in the module (same name, same +// size) the slot is rewritten to the module's address, so new code reads the +// original globals and objects built by new code carry the original vtables. +// Function slots are rewritten too when the module's entry is hooked right now, +// so a pointer taken by new code follows later reloads through the hook. +#ifndef NTRE_HR_GOT_H +#define NTRE_HR_GOT_H + +#include +#include +#include +#include + +#include "hr_elf.h" +#include "hr_hook.h" +#include "hr_log.h" + +namespace hr { + +struct GotRebindInput { + const elf::File* shim = nullptr; // mapped shim file + uintptr_t shim_bias = 0; // l_addr of the shim + uintptr_t module_base = 0; // load bias of the module + const std::vector* module_symbols = nullptr; + const std::map>* module_objects = nullptr; // name -> module_symbols indexes (STT_OBJECT) + const std::map>* module_funcs = nullptr; // name -> module_symbols indexes (STT_FUNC) + const std::map* hooks = nullptr; // module entry -> patch (to rebind function slots) +}; + +struct GotRebindOutcome { + uint32_t rebound = 0; // distinct symbols whose slots now point at the module + std::vector warnings; +}; + +bool rebind_globals(const GotRebindInput& in, GotRebindOutcome& out, const Logger& log); + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_hook.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_hook.cpp new file mode 100644 index 000000000..60eed5140 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_hook.cpp @@ -0,0 +1,173 @@ +#include "hr_hook.h" + +#include +#include + +#include +#include +#include + +namespace hr { +namespace hook { + +bool short_reaches(uintptr_t entry, uintptr_t target) { + int64_t d = static_cast(target - (entry + kShortSize)); + return d >= INT32_MIN && d <= INT32_MAX; +} + +void encode(Form form, uintptr_t entry, uintptr_t target, uint8_t* out) { + if (form == Form::Short) { + // E9 rel32 jmp target + int32_t rel = static_cast(static_cast(target - (entry + kShortSize))); + out[0] = 0xE9; + memcpy(out + 1, &rel, 4); + return; + } + // 49 BB imm64 movabs $target, %r11 + // 41 FF E3 jmp *%r11 + out[0] = 0x49; + out[1] = 0xBB; + memcpy(out + 2, &target, 8); + out[10] = 0x41; + out[11] = 0xFF; + out[12] = 0xE3; +} + +std::string offsets(uint32_t mask) { + std::string s; + for (size_t i = 0; i < kPatchSize; ++i) { + if (!(mask & (1u << i))) continue; + if (!s.empty()) s += ","; + s += std::to_string(i); + } + return s; +} + +uint32_t scan(uintptr_t entry, const uint8_t* expected, size_t span, uint32_t* foreign_mask) { + const uint8_t* mem = reinterpret_cast(entry); + uint32_t int3 = 0, foreign = 0; + for (size_t i = 0; i < span && i < kPatchSize; ++i) { + if (mem[i] == expected[i]) continue; + if (mem[i] == kInt3) int3 |= 1u << i; + else foreign |= 1u << i; + } + if (foreign_mask) *foreign_mask = foreign; + return int3; +} + +namespace { + +bool with_writable(uintptr_t addr, size_t len, std::string& err, void (*fn)(void*), void* ctx) { + long ps = sysconf(_SC_PAGESIZE); + uintptr_t page = addr & ~static_cast(ps - 1); + size_t span = (addr + len) - page; + span = (span + static_cast(ps) - 1) & ~static_cast(ps - 1); + if (mprotect(reinterpret_cast(page), span, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { + err = std::string("mprotect(rwx): ") + strerror(errno); + return false; + } + fn(ctx); + if (mprotect(reinterpret_cast(page), span, PROT_READ | PROT_EXEC) != 0) { + err = std::string("mprotect(rx): ") + strerror(errno); + return false; + } + return true; +} + +struct WriteCtx { + uintptr_t dst; + const uint8_t* src; + size_t len; +}; + +void do_write(void* c) { + WriteCtx* w = static_cast(c); + memcpy(reinterpret_cast(w->dst), w->src, w->len); +} + +// The kPatchSize bytes an active patch should hold: its jump, then the saved tail. +void expected_bytes(const Patch& p, uint8_t* out) { + memcpy(out, p.saved, kPatchSize); + encode(p.form, p.original, p.target, out); +} + +} // namespace + +bool write_code(uintptr_t addr, const void* src, size_t len, std::string& err) { + if (!addr || !src || !len) { err = "write_code: bad arguments"; return false; } + WriteCtx w{addr, static_cast(src), len}; + return with_writable(addr, len, err, do_write, &w); +} + +Report install(uintptr_t original, uintptr_t target, Patch& p, const uint8_t* pristine, bool allow_short) { + Report r; + if (!original || !target) { r.err = "hook: null address"; return r; } + if (p.original && p.original != original) { r.err = "hook: patch record belongs to another entry"; return r; } + const bool first = !p.active; + if (first && !pristine) { r.err = "hook: the entry's file image is required for the first install"; return r; } + + const Form form = (allow_short && short_reaches(original, target)) ? Form::Short : Form::Long; + r.form = form; + + // What the entry holds now, and what it will hold. + uint8_t expected[kPatchSize]; + uint8_t code[kPatchSize]; + if (first) { + memcpy(expected, pristine, kPatchSize); + memcpy(code, pristine, kPatchSize); + } else { + expected_bytes(p, expected); + memcpy(code, p.saved, kPatchSize); + } + encode(form, original, target, code); + + // The bytes this write touches: the chosen form's, or all of them when the form changes (the + // other form's tail goes back to the saved bytes). + const size_t span = (!first && p.form != form) ? kPatchSize : form_size(form); + + r.int3_mask = scan(original, expected, span, &r.foreign_mask); + if (r.foreign_mask) { r.outcome = Outcome::SkippedForeign; return r; } + if (first && r.int3_mask) { r.outcome = Outcome::SkippedInt3; return r; } + + // Re-point: keep a breakpoint that sits on a byte the write leaves unchanged (the debugger + // will restore exactly that byte later); one on a byte that changes has to go, and the caller + // tells the developer, because the debugger's restore will then corrupt the jump. + bool around = false, over = false; + for (size_t i = 0; i < span; ++i) { + if (!(r.int3_mask & (1u << i))) continue; + if (code[i] == expected[i]) { code[i] = kInt3; around = true; } + else over = true; + } + WriteCtx w{original, code, span}; + if (!with_writable(original, span, r.err, do_write, &w)) { r.outcome = Outcome::Failed; return r; } + if (first) { + p.original = original; + memcpy(p.saved, pristine, kPatchSize); + } + p.target = target; + p.form = form; + p.active = true; + r.outcome = first ? Outcome::Installed : over ? Outcome::RepointedOverInt3 : around ? Outcome::RepointedAroundInt3 : Outcome::Repointed; + return r; +} + +bool restore(Patch& p, std::string& err) { + if (!p.original || !p.active) return true; + uint8_t expected[kPatchSize]; + expected_bytes(p, expected); + const size_t span = form_size(p.form); + uint32_t foreign = 0; + uint32_t int3 = scan(p.original, expected, span, &foreign); + uint8_t code[kPatchSize]; + memcpy(code, p.saved, kPatchSize); + for (size_t i = 0; i < span; ++i) + if (int3 & (1u << i)) code[i] = kInt3; // the debugger owns that byte now + WriteCtx w{p.original, code, span}; + if (!with_writable(p.original, span, err, do_write, &w)) return false; + p.active = false; + p.target = 0; + return true; +} + +} // namespace hook +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_hook.h b/src/game/shared/neo/hotreload/vendor/src/hr_hook.h new file mode 100644 index 000000000..39f5c8597 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_hook.h @@ -0,0 +1,99 @@ +// Entry hooking: redirect the original function's entry to the +// shim's copy. Two forms: +// short E9 rel32 5 bytes, when the shim is within 2 GB of the entry +// (always the case when it landed in its region slot) +// long 49 BB imm64 ; 41 FF E3 13 bytes, movabs $target, %r11 ; jmp *%r11, otherwise +// The original entry is always the patch site, so re-hooking the same function +// for a newer shim just rewrites the jump. Safe because the module is built +// with -falign-functions=16. +// +// The short form matters for debuggers: a breakpoint placed after the prologue +// of the module's copy (the usual "break at function" spot, offset 8 to 11 at +// -O0) lands on bytes the short form never touches and never executes, so it +// is harmless there and fires in the shim instead; under the long form it +// would land inside the movabs and corrupt the jump. +// +// Debuggers also share the bytes we write: a software breakpoint is a 0xCC +// (int3) poked over the first byte of an instruction, and the debugger later +// writes its saved byte back. The hook therefore compares the bytes it is +// about to write with what they should hold (the module file's bytes, or the +// jump it installed earlier) and reports what it found. +// +// Not thread safe against threads executing the patched function while the +// bytes are written; apply on the frame thread only. +#ifndef NTRE_HR_HOOK_H +#define NTRE_HR_HOOK_H + +#include +#include +#include + +namespace hr { +namespace hook { + +constexpr size_t kPatchSize = 13; // the long form; also how many entry bytes are saved +constexpr size_t kShortSize = 5; +constexpr uint8_t kInt3 = 0xCC; // the one-byte x86 breakpoint instruction debuggers poke into code + +enum class Form : uint8_t { Short, Long }; +constexpr size_t form_size(Form f) { return f == Form::Short ? kShortSize : kPatchSize; } + +// Whether `jmp rel32` at `entry` reaches `target`. +bool short_reaches(uintptr_t entry, uintptr_t target); + +// Write the jump of the given form into out (form_size(form) bytes). +void encode(Form form, uintptr_t entry, uintptr_t target, uint8_t* out); + +struct Patch { + uintptr_t original = 0; // patched entry + uintptr_t target = 0; // where it jumps now + uint8_t saved[kPatchSize] = {}; // the entry as the module file has it + Form form = Form::Short; // form currently installed (when active) + bool active = false; +}; + +enum class Outcome { + Installed, // first install over pristine bytes + Repointed, // jump updated, nothing else in the bytes written + RepointedAroundInt3, // a breakpoint sat on bytes the write leaves unchanged; kept in place + RepointedOverInt3, // a breakpoint sat on bytes that change; displaced (the debugger's restore will corrupt the jump) + SkippedInt3, // first install refused: a debugger breakpoint is in the bytes to write + SkippedForeign, // refused: the bytes to write differ from what they should be in some other way + Failed, // bad arguments or mprotect; see err +}; + +struct Report { + Outcome outcome = Outcome::Failed; + Form form = Form::Short; // form installed (when the outcome installed or re-pointed) + uint32_t int3_mask = 0; // bit i: offset i holds a debugger's int3 + uint32_t foreign_mask = 0; // bit i: offset i differs in some other way + std::string err; +}; + +// Compare `span` bytes at `entry` with `expected`. Bit i of the result is set where memory holds +// 0xCC and `expected` does not (a debugger's breakpoint); `foreign_mask` gets every other +// difference. +uint32_t scan(uintptr_t entry, const uint8_t* expected, size_t span, uint32_t* foreign_mask); + +// Install or re-point, choosing the short form when it reaches and `allow_short` (tests turn it +// off). `pristine` is the entry as the module file has it, kPatchSize bytes, required on the first +// install (the saved copy comes from there, never from memory, so a debugger's poke is never +// "restored" later); ignored on re-points. Only the bytes the chosen form needs are examined and +// written; all kPatchSize are when the form changes, so the other form's tail goes back to the +// saved bytes. +Report install(uintptr_t original, uintptr_t target, Patch& p, const uint8_t* pristine, bool allow_short = true); + +// Put the saved bytes back, leaving a debugger's int3 where it is. +bool restore(Patch& p, std::string& err); + +// "0" or "0,8": the offsets in a mask, for messages. +std::string offsets(uint32_t mask); + +// Write `len` bytes into mapped code (mprotect to RWX around the write, back to RX after). Used by +// the static sharer to rewrite displacements inside the shim's .text. +bool write_code(uintptr_t addr, const void* src, size_t len, std::string& err); + +} // namespace hook +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_json.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_json.cpp new file mode 100644 index 000000000..b24233c3b --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_json.cpp @@ -0,0 +1,345 @@ +#include "hr_json.h" + +#include +#include +#include +#include + +namespace hr { +namespace json { + +namespace { +const std::string kEmpty; +const Value kNull; +} // namespace + +Value Value::boolean(bool b) { Value v; v.type_ = Type::Bool; v.bool_ = b; return v; } +Value Value::integer(int64_t i) { Value v; v.type_ = Type::Number; v.is_int_ = true; v.int_ = i; v.dbl_ = static_cast(i); return v; } +Value Value::number(double d) { Value v; v.type_ = Type::Number; v.is_int_ = false; v.dbl_ = d; v.int_ = static_cast(d); return v; } +Value Value::string(const std::string& s) { Value v; v.type_ = Type::String; v.str_ = s; return v; } +Value Value::array() { Value v; v.type_ = Type::Array; return v; } +Value Value::object() { Value v; v.type_ = Type::Object; return v; } + +int64_t Value::as_int(int64_t def) const { + if (!is_number()) return def; + return is_int_ ? int_ : static_cast(dbl_); +} +double Value::as_double(double def) const { return is_number() ? dbl_ : def; } +const std::string& Value::as_string() const { return is_string() ? str_ : kEmpty; } + +size_t Value::size() const { return (is_array() || is_object()) ? items_.size() : 0; } +const Value& Value::at(size_t i) const { return (is_array() && i < items_.size()) ? items_[i] : kNull; } +Value& Value::push(const Value& v) { + if (!is_array()) { *this = array(); } + items_.push_back(v); + return *this; +} +const Value* Value::get(const char* key) const { + if (!is_object()) return nullptr; + for (size_t i = 0; i < keys_.size(); ++i) + if (keys_[i] == key) return &items_[i]; + return nullptr; +} +Value& Value::set(const std::string& key, const Value& v) { + if (!is_object()) { *this = object(); } + for (size_t i = 0; i < keys_.size(); ++i) + if (keys_[i] == key) { items_[i] = v; return *this; } + keys_.push_back(key); + items_.push_back(v); + return *this; +} + +namespace { + +void escape_into(std::string& out, const std::string& s) { + out.push_back('"'); + for (unsigned char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (c < 0x20) { + char buf[8]; + snprintf(buf, sizeof buf, "\\u%04x", c); + out += buf; + } else { + out.push_back(static_cast(c)); + } + } + } + out.push_back('"'); +} + +void newline(std::string& out, int indent, int depth) { + if (indent <= 0) return; + out.push_back('\n'); + out.append(static_cast(indent * depth), ' '); +} + +} // namespace + +void Value::dump_into(std::string& out, int indent, int depth) const { + switch (type_) { + case Type::Null: out += "null"; break; + case Type::Bool: out += bool_ ? "true" : "false"; break; + case Type::Number: { + char buf[64]; + if (is_int_) { + snprintf(buf, sizeof buf, "%lld", static_cast(int_)); + } else if (std::isfinite(dbl_)) { + snprintf(buf, sizeof buf, "%.17g", dbl_); + } else { + snprintf(buf, sizeof buf, "null"); + } + out += buf; + break; + } + case Type::String: escape_into(out, str_); break; + case Type::Array: + out.push_back('['); + for (size_t i = 0; i < items_.size(); ++i) { + if (i) out.push_back(','); + newline(out, indent, depth + 1); + items_[i].dump_into(out, indent, depth + 1); + } + if (!items_.empty()) newline(out, indent, depth); + out.push_back(']'); + break; + case Type::Object: + out.push_back('{'); + for (size_t i = 0; i < keys_.size(); ++i) { + if (i) out.push_back(','); + newline(out, indent, depth + 1); + escape_into(out, keys_[i]); + out += indent > 0 ? ": " : ":"; + items_[i].dump_into(out, indent, depth + 1); + } + if (!keys_.empty()) newline(out, indent, depth); + out.push_back('}'); + break; + } +} + +std::string Value::dump(int indent) const { + std::string out; + dump_into(out, indent, 0); + if (indent > 0) out.push_back('\n'); + return out; +} + +// ---- parser ------------------------------------------------------------- + +namespace { + +class Parser { +public: + Parser(const std::string& text, std::string& err) : s_(text.data()), n_(text.size()), err_(err) {} + + bool parse_document(Value& out) { + skip_ws(); + if (!parse_value(out, 0)) return false; + skip_ws(); + if (pos_ != n_) return fail("trailing characters"); + return true; + } + +private: + bool fail(const char* what) { + char buf[128]; + snprintf(buf, sizeof buf, "json: %s at offset %zu", what, pos_); + err_ = buf; + return false; + } + + void skip_ws() { + while (pos_ < n_ && (s_[pos_] == ' ' || s_[pos_] == '\t' || s_[pos_] == '\n' || s_[pos_] == '\r')) ++pos_; + } + + bool match_literal(const char* lit) { + size_t len = strlen(lit); + if (pos_ + len <= n_ && memcmp(s_ + pos_, lit, len) == 0) { pos_ += len; return true; } + return false; + } + + bool parse_value(Value& out, int depth) { + if (depth > 64) return fail("nesting too deep"); + if (pos_ >= n_) return fail("unexpected end of input"); + char c = s_[pos_]; + if (c == '{') return parse_object(out, depth); + if (c == '[') return parse_array(out, depth); + if (c == '"') { std::string str; if (!parse_string(str)) return false; out = Value::string(str); return true; } + if (c == 't') { if (match_literal("true")) { out = Value::boolean(true); return true; } return fail("bad literal"); } + if (c == 'f') { if (match_literal("false")) { out = Value::boolean(false); return true; } return fail("bad literal"); } + if (c == 'n') { if (match_literal("null")) { out = Value::null(); return true; } return fail("bad literal"); } + if (c == '-' || (c >= '0' && c <= '9')) return parse_number(out); + return fail("unexpected character"); + } + + bool parse_number(Value& out) { + size_t start = pos_; + bool is_int = true; + if (s_[pos_] == '-') ++pos_; + if (pos_ >= n_ || !(s_[pos_] >= '0' && s_[pos_] <= '9')) return fail("bad number"); + if (s_[pos_] == '0') { ++pos_; } + else { while (pos_ < n_ && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; } + if (pos_ < n_ && s_[pos_] == '.') { + is_int = false; ++pos_; + if (pos_ >= n_ || !(s_[pos_] >= '0' && s_[pos_] <= '9')) return fail("bad fraction"); + while (pos_ < n_ && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; + } + if (pos_ < n_ && (s_[pos_] == 'e' || s_[pos_] == 'E')) { + is_int = false; ++pos_; + if (pos_ < n_ && (s_[pos_] == '+' || s_[pos_] == '-')) ++pos_; + if (pos_ >= n_ || !(s_[pos_] >= '0' && s_[pos_] <= '9')) return fail("bad exponent"); + while (pos_ < n_ && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; + } + std::string tok(s_ + start, pos_ - start); + if (is_int) { + errno = 0; + long long v = strtoll(tok.c_str(), nullptr, 10); + if (errno == ERANGE) { out = Value::number(strtod(tok.c_str(), nullptr)); } + else { out = Value::integer(v); } + } else { + out = Value::number(strtod(tok.c_str(), nullptr)); + } + return true; + } + + static void append_utf8(std::string& out, uint32_t cp) { + if (cp < 0x80) { out.push_back(static_cast(cp)); } + else if (cp < 0x800) { out.push_back(static_cast(0xC0 | (cp >> 6))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + else if (cp < 0x10000) { out.push_back(static_cast(0xE0 | (cp >> 12))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + else { out.push_back(static_cast(0xF0 | (cp >> 18))); out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); out.push_back(static_cast(0x80 | (cp & 0x3F))); } + } + + bool parse_hex4(uint32_t& v) { + if (pos_ + 4 > n_) return fail("bad unicode escape"); + v = 0; + for (int i = 0; i < 4; ++i) { + char c = s_[pos_++]; + v <<= 4; + if (c >= '0' && c <= '9') v |= static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') v |= static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') v |= static_cast(c - 'A' + 10); + else return fail("bad unicode escape"); + } + return true; + } + + bool parse_string(std::string& out) { + ++pos_; // opening quote + while (true) { + if (pos_ >= n_) return fail("unterminated string"); + unsigned char c = static_cast(s_[pos_++]); + if (c == '"') return true; + if (c < 0x20) return fail("control character in string"); + if (c != '\\') { out.push_back(static_cast(c)); continue; } + if (pos_ >= n_) return fail("bad escape"); + char e = s_[pos_++]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + uint32_t cp; + if (!parse_hex4(cp)) return false; + if (cp >= 0xD800 && cp <= 0xDBFF) { + if (pos_ + 6 <= n_ && s_[pos_] == '\\' && s_[pos_ + 1] == 'u') { + pos_ += 2; + uint32_t lo; + if (!parse_hex4(lo)) return false; + if (lo >= 0xDC00 && lo <= 0xDFFF) cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + else return fail("bad surrogate pair"); + } else { + return fail("lone surrogate"); + } + } + append_utf8(out, cp); + break; + } + default: return fail("bad escape"); + } + } + } + + bool parse_array(Value& out, int depth) { + ++pos_; + out = Value::array(); + skip_ws(); + if (pos_ < n_ && s_[pos_] == ']') { ++pos_; return true; } + while (true) { + skip_ws(); + Value item; + if (!parse_value(item, depth + 1)) return false; + out.push(item); + skip_ws(); + if (pos_ >= n_) return fail("unterminated array"); + if (s_[pos_] == ',') { ++pos_; continue; } + if (s_[pos_] == ']') { ++pos_; return true; } + return fail("expected , or ]"); + } + } + + bool parse_object(Value& out, int depth) { + ++pos_; + out = Value::object(); + skip_ws(); + if (pos_ < n_ && s_[pos_] == '}') { ++pos_; return true; } + while (true) { + skip_ws(); + if (pos_ >= n_ || s_[pos_] != '"') return fail("expected key"); + std::string key; + if (!parse_string(key)) return false; + skip_ws(); + if (pos_ >= n_ || s_[pos_] != ':') return fail("expected :"); + ++pos_; + skip_ws(); + Value item; + if (!parse_value(item, depth + 1)) return false; + out.set(key, item); + skip_ws(); + if (pos_ >= n_) return fail("unterminated object"); + if (s_[pos_] == ',') { ++pos_; continue; } + if (s_[pos_] == '}') { ++pos_; return true; } + return fail("expected , or }"); + } + } + + const char* s_; + size_t n_; + size_t pos_ = 0; + std::string& err_; +}; + +} // namespace + +bool parse(const std::string& text, Value& out, std::string& err) { + Parser p(text, err); + return p.parse_document(out); +} + +int64_t get_int(const Value& obj, const char* key, int64_t def) { + const Value* v = obj.get(key); + return v ? v->as_int(def) : def; +} +bool get_bool(const Value& obj, const char* key, bool def) { + const Value* v = obj.get(key); + return v ? v->as_bool(def) : def; +} +std::string get_string(const Value& obj, const char* key, const std::string& def) { + const Value* v = obj.get(key); + return (v && v->is_string()) ? v->as_string() : def; +} + +} // namespace json +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_json.h b/src/game/shared/neo/hotreload/vendor/src/hr_json.h new file mode 100644 index 000000000..8020bc45e --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_json.h @@ -0,0 +1,78 @@ +// Minimal JSON value, parser and serializer for the mailbox files. +// Deliberately tiny and dependency free so the vendored loader stays readable. +// Supports the full JSON grammar (RFC 8259); numbers are kept as int64 when +// they are written without fraction or exponent, otherwise as double. +#ifndef NTRE_HR_JSON_H +#define NTRE_HR_JSON_H + +#include +#include +#include + +namespace hr { +namespace json { + +enum class Type { Null, Bool, Number, String, Array, Object }; + +class Value { +public: + Value() = default; + + static Value null() { return Value(); } + static Value boolean(bool b); + static Value integer(int64_t i); + static Value number(double d); + static Value string(const std::string& s); + static Value array(); + static Value object(); + + Type type() const { return type_; } + bool is_null() const { return type_ == Type::Null; } + bool is_bool() const { return type_ == Type::Bool; } + bool is_number() const { return type_ == Type::Number; } + bool is_string() const { return type_ == Type::String; } + bool is_array() const { return type_ == Type::Array; } + bool is_object() const { return type_ == Type::Object; } + + bool as_bool(bool def = false) const { return is_bool() ? bool_ : def; } + int64_t as_int(int64_t def = 0) const; + double as_double(double def = 0.0) const; + const std::string& as_string() const; + + // Arrays and objects. + size_t size() const; + const Value& at(size_t i) const; // arrays; returns a null value when out of range + Value& push(const Value& v); // arrays; returns *this + const Value* get(const char* key) const; // objects; nullptr when missing + Value& set(const std::string& key, const Value& v); // objects; replaces; returns *this + const std::vector& keys() const { return keys_; } + const std::vector& items() const { return items_; } // array items, or object values in key order + + // Serialize. indent <= 0 gives a single line. + std::string dump(int indent = 2) const; + +private: + void dump_into(std::string& out, int indent, int depth) const; + + Type type_ = Type::Null; + bool bool_ = false; + bool is_int_ = false; + int64_t int_ = 0; + double dbl_ = 0.0; + std::string str_; + std::vector keys_; // objects only + std::vector items_; // arrays and objects +}; + +// Parse a complete document. On failure returns false and describes the error with offset. +bool parse(const std::string& text, Value& out, std::string& err); + +// Convenience accessors with defaults, for flat protocol objects. +int64_t get_int(const Value& obj, const char* key, int64_t def); +bool get_bool(const Value& obj, const char* key, bool def); +std::string get_string(const Value& obj, const char* key, const std::string& def); + +} // namespace json +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_log.h b/src/game/shared/neo/hotreload/vendor/src/hr_log.h new file mode 100644 index 000000000..0e5943c24 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_log.h @@ -0,0 +1,69 @@ +// Internal logging helper around the ntre_hr_log_fn callback. +#ifndef NTRE_HR_LOG_H +#define NTRE_HR_LOG_H + +#include "ntre_hr.h" + +#include +#include + +namespace hr { + +struct Logger { + ntre_hr_log_fn fn = nullptr; + void* user = nullptr; + bool verbose = false; + + void log(ntre_hr_log_level level, const char* fmt, ...) const __attribute__((format(printf, 3, 4))) { + if (level == NTRE_HR_LOG_DEBUG && !verbose) return; + char buf[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (fn) { + fn(user, level, buf); + } else { + static const char* const names[] = {"debug", "info", "warn", "error"}; + fprintf(stderr, "[ntre_hr %s] %s\n", names[level & 3], buf); + } + } + + void debug(const char* fmt, ...) const __attribute__((format(printf, 2, 3))) { + if (!verbose) return; + char buf[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + log(NTRE_HR_LOG_DEBUG, "%s", buf); + } + void info(const char* fmt, ...) const __attribute__((format(printf, 2, 3))) { + char buf[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + log(NTRE_HR_LOG_INFO, "%s", buf); + } + void warn(const char* fmt, ...) const __attribute__((format(printf, 2, 3))) { + char buf[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + log(NTRE_HR_LOG_WARN, "%s", buf); + } + void error(const char* fmt, ...) const __attribute__((format(printf, 2, 3))) { + char buf[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + log(NTRE_HR_LOG_ERROR, "%s", buf); + } +}; + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.cpp new file mode 100644 index 000000000..5de0a3490 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.cpp @@ -0,0 +1,162 @@ +#include "hr_mailbox.h" + +#include +#include +#include +#include +#include + +#include "hr_paths.h" +#include "ntre_hr_protocol.h" + +namespace hr { + +std::string state_file_name(const std::string& module) { return std::string(NTRE_HR_STATE_PREFIX) + module + NTRE_HR_STATE_SUFFIX; } +std::string shim_file_name(const std::string& module, uint32_t seq) { return module + "." + std::to_string(seq) + NTRE_HR_SHIM_SO_SUFFIX; } +std::string manifest_file_name(const std::string& module, uint32_t seq) { return module + "." + std::to_string(seq) + NTRE_HR_SHIM_MANIFEST_SUFFIX; } +std::string result_file_name(const std::string& module, uint32_t seq) { return module + "." + std::to_string(seq) + NTRE_HR_RESULT_SUFFIX; } + +bool parse_manifest_name(const std::string& name, const std::string& module, uint32_t& seq) { + // ..json, and nothing else (so ".result.json" and ".json.tmp" do not match) + const std::string prefix = module + "."; + const std::string suffix = NTRE_HR_SHIM_MANIFEST_SUFFIX; + if (name.size() <= prefix.size() + suffix.size()) return false; + if (name.compare(0, prefix.size(), prefix) != 0) return false; + if (name.compare(name.size() - suffix.size(), suffix.size(), suffix) != 0) return false; + std::string digits = name.substr(prefix.size(), name.size() - prefix.size() - suffix.size()); + if (digits.empty() || digits.size() > 9) return false; + for (char c : digits) if (c < '0' || c > '9') return false; + seq = static_cast(strtoul(digits.c_str(), nullptr, 10)); + return seq > 0; +} + +bool parse_hex_address(const std::string& s, uintptr_t& out) { + if (s.size() < 3 || s[0] != '0' || (s[1] != 'x' && s[1] != 'X')) return false; + char* end = nullptr; + errno = 0; + unsigned long long v = strtoull(s.c_str() + 2, &end, 16); + if (errno != 0 || !end || *end != '\0') return false; + out = static_cast(v); + return true; +} + +std::string hex_address(uintptr_t a) { + char buf[32]; + snprintf(buf, sizeof buf, "0x%lx", static_cast(a)); + return buf; +} + +bool Mailbox::init(const std::string& build_dir, std::string& err) { + if (!paths::is_absolute(build_dir)) { err = "build dir must be absolute: " + build_dir; return false; } + build_dir_ = paths::normalize(build_dir); + dir_ = paths::join(build_dir_, NTRE_HR_MAILBOX_DIR); + return paths::mkdir_p(dir_, err); +} + +std::string Mailbox::abs(const std::string& rel) const { return paths::normalize(paths::join(build_dir_, rel)); } +std::string Mailbox::rel(const std::string& abs_path) const { return paths::relative(build_dir_, abs_path); } + +std::string Mailbox::state_path(const std::string& module) const { return paths::join(dir_, state_file_name(module)); } +std::string Mailbox::shim_path(const std::string& module, uint32_t seq) const { return paths::join(dir_, shim_file_name(module, seq)); } +std::string Mailbox::manifest_path(const std::string& module, uint32_t seq) const { return paths::join(dir_, manifest_file_name(module, seq)); } +std::string Mailbox::result_path(const std::string& module, uint32_t seq) const { return paths::join(dir_, result_file_name(module, seq)); } +std::string Mailbox::sidecar_path() const { return paths::join(dir_, NTRE_HR_SIDECAR_FILE); } + +bool Mailbox::write_json(const std::string& path, const json::Value& v, std::string& err) const { + return paths::write_file_atomic(path, v.dump(2), err); +} + +bool Mailbox::remove(const std::string& path) const { return paths::remove_file(path); } + +std::vector Mailbox::manifest_seqs(const std::string& module) const { + std::vector out; + for (const std::string& name : paths::list_dir(dir_)) { + uint32_t seq; + if (parse_manifest_name(name, module, seq)) out.push_back(seq); + } + std::sort(out.begin(), out.end()); + return out; +} + +bool Mailbox::parse_manifest(const std::string& text, Manifest& out, std::string& err) const { + json::Value v; + if (!json::parse(text, v, err)) return false; + if (!v.is_object()) { err = "manifest is not an object"; return false; } + out = Manifest(); + out.protocol = json::get_int(v, "protocol", -1); + out.build_id = json::get_string(v, "build_id", ""); + out.module = json::get_string(v, "module", ""); + int64_t seq = json::get_int(v, "seq", 0); + out.shim = json::get_string(v, "shim", ""); + out.created_unix_ms = json::get_int(v, "created_unix_ms", 0); + if (out.protocol < 0) { err = "manifest: missing protocol"; return false; } + if (seq > 0 && seq <= static_cast(UINT32_MAX)) out.seq = static_cast(seq); + // A manifest from another protocol version is returned as far as it parsed: the caller rejects + // it by version instead of guessing which field of an unknown shape is missing. + if (out.protocol != NTRE_HR_PROTOCOL_VERSION) return true; + if (out.module.empty() || out.seq == 0 || out.shim.empty()) { err = "manifest: missing module, seq or shim"; return false; } + if (const json::Value* lb = v.get("link_base")) { + if (lb->is_string()) { + if (!parse_hex_address(lb->as_string(), out.link_base)) { err = "manifest: bad link_base"; return false; } + out.has_link_base = true; + } + } + if (const json::Value* mb = v.get("module_base")) { + if (mb->is_string()) { + if (!parse_hex_address(mb->as_string(), out.module_base)) { err = "manifest: bad module_base"; return false; } + out.has_module_base = true; + } + } + out.session = json::get_string(v, "session", ""); + const json::Value* slot = v.get("slot"); + const json::Value* slots = v.get("slots"); + if (slot && slots && slot->is_number() && slots->is_number()) { + out.has_slot = true; + out.slot = static_cast(slot->as_int()); + out.slots = static_cast(slots->as_int()); + if (out.slots == 0) out.slots = 1; + } + const json::Value* units = v.get("units"); + if (!units || !units->is_array()) { err = "manifest: missing units"; return false; } + for (size_t i = 0; i < units->size(); ++i) { + const json::Value& u = units->at(i); + ManifestUnit mu; + mu.source = json::get_string(u, "source", ""); + mu.object = json::get_string(u, "object", ""); + if (mu.object.empty()) { err = "manifest: unit without object"; return false; } + out.units.push_back(mu); + } + return true; +} + +bool Mailbox::read_manifest(const std::string& module, uint32_t seq, Manifest& out, std::string& err) const { + std::string path = manifest_path(module, seq); + std::string text; + if (!paths::read_file(path, text, err)) return false; + if (!parse_manifest(text, out, err)) { err = path + ": " + err; return false; } + out.manifest_path = path; + out.shim_path = paths::join(dir_, out.shim); + return true; +} + +SidecarPresence Mailbox::sidecar_presence(int64_t stale_ms) const { + SidecarPresence p; + std::string path = sidecar_path(); + int64_t mtime = paths::mtime_ms(path); + if (mtime < 0) return p; + std::string text, err; + if (!paths::read_file(path, text, err)) return p; + json::Value v; + if (!json::parse(text, v, err) || !v.is_object()) return p; + p.present = true; + p.age_ms = paths::now_unix_ms() - mtime; + if (p.age_ms < 0) p.age_ms = 0; + p.fresh = p.age_ms <= stale_ms; + p.pid = json::get_int(v, "pid", 0); + p.auto_apply = json::get_bool(v, "auto_apply", true); + p.session = json::get_string(v, "session", ""); + p.protocol = json::get_int(v, "protocol", 0); + return p; +} + +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.h b/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.h new file mode 100644 index 000000000..28827f06b --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_mailbox.h @@ -0,0 +1,94 @@ +// Mailbox reader/writer, loader side: file names, atomic +// writes, manifest parsing, sidecar presence. Everything inside mailbox files is +// relative to the build dir; this class converts to and from absolute paths. +#ifndef NTRE_HR_MAILBOX_H +#define NTRE_HR_MAILBOX_H + +#include +#include +#include + +#include "hr_json.h" + +namespace hr { + +struct ManifestUnit { + std::string source; // relative to the build dir + std::string object; // relative to the build dir +}; + +struct Manifest { + int64_t protocol = 0; + std::string build_id; + std::string module; + uint32_t seq = 0; + std::string shim; // bare file name + bool has_link_base = false; + uintptr_t link_base = 0; + bool has_slot = false; + uint32_t slot = 0; + uint32_t slots = 0; + bool has_module_base = false; // the load bias the shim's symbol script was generated for + uintptr_t module_base = 0; + std::string session; // the watch session that published it, empty for one-shot publishes + std::vector units; + int64_t created_unix_ms = 0; + + std::string manifest_path; // absolute, filled by Mailbox::read_manifest + std::string shim_path; // absolute +}; + +struct SidecarPresence { + bool present = false; // file exists and parses + bool fresh = false; // mtime younger than the stale threshold + int64_t age_ms = -1; + int64_t pid = 0; + bool auto_apply = true; + int64_t protocol = 0; + std::string session; // the watch session id, empty for old sidecars +}; + +// Pure name helpers. +std::string state_file_name(const std::string& module); +std::string shim_file_name(const std::string& module, uint32_t seq); +std::string manifest_file_name(const std::string& module, uint32_t seq); +std::string result_file_name(const std::string& module, uint32_t seq); +// "..json" -> N. False for result files, tmp files and other modules. +bool parse_manifest_name(const std::string& name, const std::string& module, uint32_t& seq); +bool parse_hex_address(const std::string& s, uintptr_t& out); // "0x7f..." (lowercase or uppercase) +std::string hex_address(uintptr_t a); // "0x7f..." + +class Mailbox { +public: + // build_dir must be absolute. Creates /.hotreload. + bool init(const std::string& build_dir, std::string& err); + + const std::string& dir() const { return dir_; } + const std::string& build_dir() const { return build_dir_; } + std::string abs(const std::string& rel_to_build_dir) const; + std::string rel(const std::string& abs_path) const; + + std::string state_path(const std::string& module) const; + std::string shim_path(const std::string& module, uint32_t seq) const; + std::string manifest_path(const std::string& module, uint32_t seq) const; + std::string result_path(const std::string& module, uint32_t seq) const; + std::string sidecar_path() const; + + bool write_json(const std::string& path, const json::Value& v, std::string& err) const; + bool remove(const std::string& path) const; + + // All manifest sequence numbers present for a module, ascending. + std::vector manifest_seqs(const std::string& module) const; + bool read_manifest(const std::string& module, uint32_t seq, Manifest& out, std::string& err) const; + bool parse_manifest(const std::string& text, Manifest& out, std::string& err) const; + + SidecarPresence sidecar_presence(int64_t stale_ms) const; + +private: + std::string dir_; + std::string build_dir_; +}; + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_paths.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_paths.cpp new file mode 100644 index 000000000..1391a52b1 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_paths.cpp @@ -0,0 +1,205 @@ +#include "hr_paths.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace hr { +namespace paths { + +bool is_absolute(const std::string& p) { return !p.empty() && p[0] == '/'; } + +std::string dirname(const std::string& p) { + if (p.empty()) return "."; + std::string s = p; + while (s.size() > 1 && s.back() == '/') s.pop_back(); + size_t slash = s.rfind('/'); + if (slash == std::string::npos) return "."; + if (slash == 0) return "/"; + return s.substr(0, slash); +} + +std::string basename(const std::string& p) { + std::string s = p; + while (s.size() > 1 && s.back() == '/') s.pop_back(); + size_t slash = s.rfind('/'); + return slash == std::string::npos ? s : s.substr(slash + 1); +} + +std::string join(const std::string& a, const std::string& b) { + if (b.empty()) return a; + if (is_absolute(b) || a.empty()) return b; + if (a.back() == '/') return a + b; + return a + "/" + b; +} + +std::string normalize(const std::string& p) { + std::vector parts; + bool abs = is_absolute(p); + size_t i = 0; + while (i < p.size()) { + size_t j = p.find('/', i); + if (j == std::string::npos) j = p.size(); + std::string seg = p.substr(i, j - i); + if (seg.empty() || seg == ".") { + // skip + } else if (seg == "..") { + if (!parts.empty() && parts.back() != "..") parts.pop_back(); + else if (!abs) parts.push_back(".."); + } else { + parts.push_back(seg); + } + i = j + 1; + } + std::string out = abs ? "/" : ""; + for (size_t k = 0; k < parts.size(); ++k) { + if (k) out += "/"; + out += parts[k]; + } + if (out.empty()) out = abs ? "/" : "."; + return out; +} + +std::string realpath_or(const std::string& p) { + char buf[PATH_MAX]; + if (::realpath(p.c_str(), buf)) return std::string(buf); + return normalize(p); +} + +std::string relative(const std::string& from_dir, const std::string& to) { + std::string a = normalize(from_dir); + std::string b = normalize(to); + if (!is_absolute(a) || !is_absolute(b)) return b; + std::vector pa, pb; + auto split = [](const std::string& s, std::vector& out) { + size_t i = 1; + while (i <= s.size()) { + size_t j = s.find('/', i); + if (j == std::string::npos) j = s.size(); + if (j > i) out.push_back(s.substr(i, j - i)); + i = j + 1; + } + }; + split(a, pa); + split(b, pb); + size_t common = 0; + while (common < pa.size() && common < pb.size() && pa[common] == pb[common]) ++common; + std::string out; + for (size_t k = common; k < pa.size(); ++k) out += out.empty() ? ".." : "/.."; + for (size_t k = common; k < pb.size(); ++k) out += out.empty() ? pb[k] : "/" + pb[k]; + if (out.empty()) out = "."; + return out; +} + +bool exists(const std::string& p) { + struct stat st; + return ::stat(p.c_str(), &st) == 0; +} + +bool is_dir(const std::string& p) { + struct stat st; + return ::stat(p.c_str(), &st) == 0 && S_ISDIR(st.st_mode); +} + +bool mkdir_p(const std::string& p, std::string& err) { + std::string cur; + std::string n = normalize(p); + size_t i = 0; + if (is_absolute(n)) { cur = "/"; i = 1; } + while (i <= n.size()) { + size_t j = n.find('/', i); + if (j == std::string::npos) j = n.size(); + if (j > i) { + cur = join(cur, n.substr(i, j - i)); + if (::mkdir(cur.c_str(), 0775) != 0 && errno != EEXIST) { + err = "mkdir " + cur + ": " + strerror(errno); + return false; + } + } + i = j + 1; + } + if (!is_dir(n)) { err = n + " is not a directory"; return false; } + return true; +} + +bool read_file(const std::string& p, std::string& out, std::string& err) { + FILE* f = fopen(p.c_str(), "rb"); + if (!f) { err = p + ": " + strerror(errno); return false; } + out.clear(); + char buf[65536]; + size_t n; + while ((n = fread(buf, 1, sizeof buf, f)) > 0) out.append(buf, n); + bool ok = !ferror(f); + if (!ok) err = p + ": read error"; + fclose(f); + return ok; +} + +bool write_file_atomic(const std::string& p, const std::string& data, std::string& err) { + std::string tmp = p + ".tmp"; + FILE* f = fopen(tmp.c_str(), "wb"); + if (!f) { err = tmp + ": " + strerror(errno); return false; } + bool ok = fwrite(data.data(), 1, data.size(), f) == data.size(); + ok = (fflush(f) == 0) && ok; + ok = (fclose(f) == 0) && ok; + if (!ok) { err = tmp + ": write error"; ::unlink(tmp.c_str()); return false; } + if (::rename(tmp.c_str(), p.c_str()) != 0) { + err = "rename " + tmp + " -> " + p + ": " + strerror(errno); + ::unlink(tmp.c_str()); + return false; + } + return true; +} + +bool remove_file(const std::string& p) { return ::unlink(p.c_str()) == 0; } + +int64_t mtime_ms(const std::string& p) { + struct stat st; + if (::stat(p.c_str(), &st) != 0) return -1; + return static_cast(st.st_mtim.tv_sec) * 1000 + st.st_mtim.tv_nsec / 1000000; +} + +std::vector list_dir(const std::string& p) { + std::vector out; + DIR* d = ::opendir(p.c_str()); + if (!d) return out; + while (struct dirent* e = ::readdir(d)) { + if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue; + out.push_back(e->d_name); + } + ::closedir(d); + return out; +} + +int64_t now_unix_ms() { + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + return static_cast(ts.tv_sec) * 1000 + ts.tv_nsec / 1000000; +} + +int64_t now_mono_ms() { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return static_cast(ts.tv_sec) * 1000 + ts.tv_nsec / 1000000; +} + +bool module_of(const void* anchor, std::string& path, uintptr_t& base, std::string& err) { + Dl_info info; + if (!anchor || dladdr(anchor, &info) == 0 || !info.dli_fname) { + err = "dladdr could not resolve the module anchor"; + return false; + } + path = realpath_or(info.dli_fname); + base = reinterpret_cast(info.dli_fbase); + return true; +} + +} // namespace paths +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_paths.h b/src/game/shared/neo/hotreload/vendor/src/hr_paths.h new file mode 100644 index 000000000..1471f4bb0 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_paths.h @@ -0,0 +1,43 @@ +// Filesystem and path helpers (POSIX only, no std::filesystem to keep the old +// libstdc++ ABI build simple). +#ifndef NTRE_HR_PATHS_H +#define NTRE_HR_PATHS_H + +#include +#include +#include + +namespace hr { +namespace paths { + +bool is_absolute(const std::string& p); +std::string dirname(const std::string& p); // "/a/b/c" -> "/a/b", "c" -> ".", "/" -> "/" +std::string basename(const std::string& p); // "/a/b/c.so" -> "c.so" +std::string join(const std::string& a, const std::string& b); // b absolute wins +std::string normalize(const std::string& p); // collapse ".", "..", "//" lexically (no symlink resolution) +std::string realpath_or(const std::string& p); // realpath(3), or normalize(p) when it fails +// Lexical relative path from directory `from` to path `to`, both absolute and normalized. +std::string relative(const std::string& from_dir, const std::string& to); + +bool exists(const std::string& p); +bool is_dir(const std::string& p); +bool mkdir_p(const std::string& p, std::string& err); +bool read_file(const std::string& p, std::string& out, std::string& err); +// Write to

.tmp and rename into place. +bool write_file_atomic(const std::string& p, const std::string& data, std::string& err); +bool remove_file(const std::string& p); +// mtime in unix milliseconds, or -1 when stat fails. +int64_t mtime_ms(const std::string& p); +// Directory entries (names only, no "." and ".."). +std::vector list_dir(const std::string& p); + +int64_t now_unix_ms(); +int64_t now_mono_ms(); + +// dladdr wrapper: path and load base of the object containing `anchor`. +bool module_of(const void* anchor, std::string& path, uintptr_t& base, std::string& err); + +} // namespace paths +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_region.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_region.cpp new file mode 100644 index 000000000..fe3527ba7 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_region.cpp @@ -0,0 +1,157 @@ +#include "hr_region.h" + +#include + +#include +#include + +#ifndef MAP_FIXED_NOREPLACE +#define MAP_FIXED_NOREPLACE 0x100000 +#endif + +namespace hr { + +bool pc32_reachable(uintptr_t a_lo, uintptr_t a_hi, uintptr_t b_lo, uintptr_t b_hi) { + const uint64_t limit = (1ull << 31) - (64ull << 20); // 2 GB minus a 64 MB margin + uint64_t d1 = a_hi > b_lo ? a_hi - b_lo : b_lo - a_hi; + uint64_t d2 = b_hi > a_lo ? b_hi - a_lo : a_lo - b_hi; + return d1 < limit && d2 < limit; +} + +namespace { + +const uintptr_t kAlign = 2u << 20; // 2 MB, keeps hints huge-page friendly + +uintptr_t align_down(uintptr_t v, uintptr_t a) { return v & ~(a - 1); } +uintptr_t align_up(uintptr_t v, uintptr_t a) { return (v + a - 1) & ~(a - 1); } + +void* map_at(uintptr_t hint, uint64_t size, bool fixed_noreplace) { + int flags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE; + if (fixed_noreplace) flags |= MAP_FIXED_NOREPLACE; + void* p = mmap(reinterpret_cast(hint), size, PROT_NONE, flags, -1, 0); + return p; +} + +} // namespace + +bool Region::reserve(uintptr_t mod_lo, uintptr_t mod_hi, uint64_t slot_size_, uint32_t slot_count_, + uintptr_t preferred_base, const Logger& log) { + unreserve(); + slot_size = slot_size_; + slot_count = slot_count_; + next_slot = 0; + const uint64_t total = size(); + if (total == 0) { log.error("region: zero size"); return false; } + + std::vector hints; + if (preferred_base) hints.push_back(preferred_base); + static const uint64_t gaps[] = {64ull << 20, 256ull << 20, 512ull << 20, 1024ull << 20, 1536ull << 20}; + for (uint64_t gap : gaps) { + if (mod_lo > gap + total) hints.push_back(align_down(mod_lo - gap - total, kAlign)); + } + for (uint64_t gap : gaps) hints.push_back(align_up(mod_hi + gap, kAlign)); + + for (uintptr_t hint : hints) { + void* p = map_at(hint, total, true); + if (p == MAP_FAILED) continue; + if (reinterpret_cast(p) != hint) { + // Kernel without MAP_FIXED_NOREPLACE ignored the flag and placed it elsewhere. + munmap(p, total); + continue; + } + if (!pc32_reachable(hint, hint + total, mod_lo, mod_hi)) { + munmap(p, total); + continue; + } + base = hint; + reserved = true; + in_range = true; + states.assign(slot_count, SlotState::Free); + log.info("region: reserved %u x %llu KB at 0x%lx (module 0x%lx..0x%lx, in PC32 range)", + slot_count, static_cast(slot_size >> 10), static_cast(base), + static_cast(mod_lo), static_cast(mod_hi)); + return true; + } + + // Nothing near the module was free: take whatever the kernel gives and flag it. + void* p = map_at(0, total, false); + if (p == MAP_FAILED) { + log.error("region: mmap failed: %s", strerror(errno)); + return false; + } + base = reinterpret_cast(p); + reserved = true; + in_range = pc32_reachable(base, base + total, mod_lo, mod_hi); + states.assign(slot_count, SlotState::Free); + log.warn("region: no free range near the module; reserved at 0x%lx (%s). Statics will be %s.", + static_cast(base), in_range ? "in PC32 range by luck" : "out of PC32 range", + in_range ? "shared" : "copied, restart for exact state"); + return true; +} + +bool Region::in_bounds(uint32_t first, uint32_t count) const { + return reserved && count > 0 && first < slot_count && count <= slot_count - first; +} + +bool Region::all_in_state(uint32_t first, uint32_t count, SlotState s) const { + for (uint32_t i = first; i < first + count; ++i) + if (states[i] != s) return false; + return true; +} + +void Region::set_state(uint32_t first, uint32_t count, SlotState s) { + for (uint32_t i = first; i < first + count; ++i) states[i] = s; +} + +bool Region::slots_free(uint32_t first, uint32_t count) const { + return in_bounds(first, count) && all_in_state(first, count, SlotState::Free); +} + +bool Region::release_slots(uint32_t first, uint32_t count) { + if (!slots_free(first, count)) return false; + if (munmap(reinterpret_cast(slot_addr(first)), count * slot_size) != 0) return false; + set_state(first, count, SlotState::Released); + return true; +} + +bool Region::reclaim_slots(uint32_t first, uint32_t count) { + if (!in_bounds(first, count) || !all_in_state(first, count, SlotState::Released)) return false; + uintptr_t addr = slot_addr(first); + void* p = map_at(addr, count * slot_size, true); + if (p != MAP_FAILED && reinterpret_cast(p) == addr) { + set_state(first, count, SlotState::Free); + return true; + } + if (p != MAP_FAILED) munmap(p, count * slot_size); + // Something else lives there now; never release or unmap these slots again. + set_state(first, count, SlotState::Lost); + if (first + count > next_slot) next_slot = first + count; + return false; +} + +void Region::occupy_slots(uint32_t first, uint32_t count) { + if (!in_bounds(first, count)) return; + set_state(first, count, SlotState::Occupied); + if (first + count > next_slot) next_slot = first + count; +} + +void Region::unreserve() { + if (reserved && base) { + // Only Free slots are still our mappings: Occupied ones belong to ld.so, Lost ones to whoever landed there. + uint32_t i = 0; + while (i < slot_count) { + if (states[i] != SlotState::Free) { ++i; continue; } + uint32_t j = i; + while (j < slot_count && states[j] == SlotState::Free) ++j; + munmap(reinterpret_cast(slot_addr(i)), (j - i) * slot_size); + i = j; + } + } + base = 0; + reserved = false; + in_range = false; + next_slot = 0; + states.clear(); +} + +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_region.h b/src/game/shared/neo/hotreload/vendor/src/hr_region.h new file mode 100644 index 000000000..a36d10f48 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_region.h @@ -0,0 +1,71 @@ +// Address region reservation: a PROT_NONE mapping near the +// module, carved into slots. The sidecar links each shim at a slot address; the +// loader releases the slot right before dlopen so ld.so's mmap hint (the linked +// base) lands there, which keeps shim code within PC32 reach of the module's +// statics. When no in-range reservation is possible the loader says so and +// static sharing falls back to byte copies. +// +// Every slot carries a state so the loader never unmaps memory it no longer +// owns: a slot that a shim landed in belongs to ld.so, and a slot that could +// not be reclaimed after a refused hint may belong to anyone. +#ifndef NTRE_HR_REGION_H +#define NTRE_HR_REGION_H + +#include +#include + +#include "hr_log.h" + +namespace hr { + +// Every address in [a_lo, a_hi) is within PC32 reach of every address in [b_lo, b_hi), with margin. +bool pc32_reachable(uintptr_t a_lo, uintptr_t a_hi, uintptr_t b_lo, uintptr_t b_hi); + +enum class SlotState : uint8_t { + Free, // PROT_NONE mapping owned by the loader + Released, // unmapped for a dlopen in progress + Occupied, // a shim landed here; the mapping belongs to ld.so now + Lost, // released, the shim landed elsewhere and the slot could not be reclaimed; never touched again +}; + +struct Region { + uintptr_t base = 0; + uint64_t slot_size = 0; + uint32_t slot_count = 0; + uint32_t next_slot = 0; // published to the sidecar: every slot at or above this index is Free + bool reserved = false; + bool in_range = false; + std::vector states; // one per slot, empty when not reserved + + uint64_t size() const { return slot_size * slot_count; } + uintptr_t slot_addr(uint32_t slot) const { return base + slot * slot_size; } + bool contains(uintptr_t addr, uint64_t len) const { return reserved && addr >= base && addr + len <= base + size(); } + uint32_t slots_for(uint64_t bytes) const { return slot_size ? static_cast((bytes + slot_size - 1) / slot_size) : 0; } + // In bounds and every slot Free. + bool slots_free(uint32_t first, uint32_t count) const; + + // Reserve near the module mapped at [mod_lo, mod_hi). preferred_base is tried first when + // non-zero (used to replay shims linked for a previous process). Logs what happened. + bool reserve(uintptr_t mod_lo, uintptr_t mod_hi, uint64_t slot_size, uint32_t slot_count, + uintptr_t preferred_base, const Logger& log); + + // Free -> Released: munmap the slots so dlopen can land the shim there. + bool release_slots(uint32_t first, uint32_t count); + // Released -> Free: put the PROT_NONE mapping back (the shim landed elsewhere or dlopen failed). + // When that fails the slots become Lost and next_slot moves past them. + bool reclaim_slots(uint32_t first, uint32_t count); + // Released -> Occupied: the shim landed in the slots; next_slot moves past them. + void occupy_slots(uint32_t first, uint32_t count); + + // munmap what is still ours (Free slots) and forget the reservation. + void unreserve(); + +private: + bool in_bounds(uint32_t first, uint32_t count) const; + bool all_in_state(uint32_t first, uint32_t count, SlotState s) const; + void set_state(uint32_t first, uint32_t count, SlotState s); +}; + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_statics.cpp b/src/game/shared/neo/hotreload/vendor/src/hr_statics.cpp new file mode 100644 index 000000000..770973f90 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_statics.cpp @@ -0,0 +1,167 @@ +#include "hr_statics.h" + +#include + +#include +#include +#include + +#include "hr_hook.h" +#include "hr_paths.h" + +namespace hr { + +namespace { + +// One shim static with every site that references it. +struct Candidate { + const elf::Symbol* shim_sym = nullptr; + const elf::Symbol* module_sym = nullptr; + uintptr_t shim_addr = 0; // runtime address of the shim's copy + uintptr_t module_addr = 0; // runtime address of the module's copy + std::vector sites; // runtime addresses of the 32-bit displacement fields +}; + +// The one STT_OBJECT a --unique section holds: defined at the section's address, as large as the section. +const elf::Symbol* section_owner(const std::vector& syms, uint16_t shndx, const elf::Section& sec) { + for (const elf::Symbol& s : syms) { + if (s.type != STT_OBJECT || s.shndx != shndx || s.value != sec.addr || s.size == 0) continue; + if (s.size != sec.size) return nullptr; // more than one object in there: not a per-static section + return &s; + } + return nullptr; +} + +// The module's copy: locals by name and STT_FILE basename, others by name. +const elf::Symbol* match_module_object(const StaticShareInput& in, const elf::Symbol& shim_sym) { + auto it = in.module_objects->find(shim_sym.name); + if (it == in.module_objects->end()) return nullptr; + if (shim_sym.bind == STB_LOCAL) { + std::string want = paths::basename(shim_sym.file); + for (size_t i : it->second) { + const elf::Symbol& m = (*in.module_symbols)[i]; + if (m.bind == STB_LOCAL && paths::basename(m.file) == want) return &m; + } + return nullptr; + } + for (size_t i : it->second) { + const elf::Symbol& m = (*in.module_symbols)[i]; + if (m.bind != STB_LOCAL) return &m; + } + return nullptr; +} + +bool starts_with(const std::string& s, const char* p) { return s.compare(0, strlen(p), p) == 0; } + +} // namespace + +bool share_statics(const StaticShareInput& in, StaticShareOutcome& out, const Logger& log) { + out = StaticShareOutcome(); + const elf::File& shim = *in.shim; + const std::vector& secs = shim.sections(); + const std::vector& syms = *in.shim_symbols; + + int symtab_index = shim.find_section_index(".symtab"); + std::vector relas; + for (const elf::Section& s : secs) + if (s.type == SHT_RELA && starts_with(s.name, ".rela.text") && static_cast(s.link) == symtab_index) relas.push_back(&s); + if (relas.empty()) { + out.warnings.push_back("shim carries no .rela.text, statics stay private copies: link shims with -Wl,--emit-relocs -Wl,--unique=.data.* -Wl,--unique=.bss.* (update the sidecar)"); + return true; + } + + uint64_t relro_lo = 0, relro_hi = 0; + const bool has_relro = shim.relro_extent(relro_lo, relro_hi); + + // Sites inside the shim's static-init glue ran at dlopen and never run again; leave them alone. + std::vector> init_ranges; + for (const elf::Symbol& s : syms) + if (s.type == STT_FUNC && s.size && elf::is_crt_symbol(s.name)) init_ranges.push_back({s.value, s.value + s.size}); + auto in_init = [&](uint64_t off) { + for (const auto& r : init_ranges) + if (off >= r.first && off < r.second) return true; + return false; + }; + + std::map cands; + uint32_t sites = 0, init_sites = 0; + std::string err; + for (const elf::Section* rs : relas) { + std::vector rel; + if (!shim.read_rela(*rs, rel, err)) { out.warnings.push_back(err); continue; } + for (const elf::Rela& r : rel) { + if (r.type != R_X86_64_PC32 || r.sym >= syms.size()) continue; + const elf::Symbol& ss = syms[r.sym]; + if (ss.type != STT_SECTION || ss.shndx >= secs.size()) continue; + const elf::Section& sec = secs[ss.shndx]; + // Only per-static sections (--unique over -fdata-sections); plain .data/.bss hold anything. + if (!starts_with(sec.name, ".data.") && !starts_with(sec.name, ".bss.")) continue; + if (!(sec.flags & SHF_ALLOC) || !(sec.flags & SHF_WRITE) || (sec.flags & SHF_TLS)) continue; + if (has_relro && sec.addr >= relro_lo && sec.addr < relro_hi) continue; // read-only after relocation: nothing to share + const elf::Symbol* owner = section_owner(syms, ss.shndx, sec); + if (!owner) continue; + if (in_init(r.offset)) { init_sites++; continue; } + Candidate& c = cands[owner]; + if (!c.shim_sym) { + c.shim_sym = owner; + c.module_sym = match_module_object(in, *owner); + c.shim_addr = in.shim_bias + owner->value; + if (c.module_sym) c.module_addr = in.module_base + c.module_sym->value; + } + c.sites.push_back(in.shim_bias + r.offset); + sites++; + } + } + + uint32_t fresh = 0; + for (auto& kv : cands) { + Candidate& c = kv.second; + const std::string& name = c.shim_sym->name; + if (!c.module_sym) { + fresh++; + log.debug("statics: %s is new, stays shim-owned (%zu site(s))", name.c_str(), c.sites.size()); + continue; + } + if (c.module_sym->size != c.shim_sym->size) { + out.warnings.push_back(name + ": size differs between the module (" + std::to_string(c.module_sym->size) + ") and the shim (" + + std::to_string(c.shim_sym->size) + "), not shared; its type changed, restart for exact state"); + continue; + } + if (!in.in_pc32_range) { + // One-time copy of the module's bytes over the shim's copy; state diverges from here. + memcpy(reinterpret_cast(c.shim_addr), reinterpret_cast(c.module_addr), c.shim_sym->size); + out.copied++; + continue; + } + // Every site: displacement += module copy - shim copy. The instruction's own layout cancels out. + const int64_t delta = static_cast(c.module_addr - c.shim_addr); + bool ok = true; + for (uintptr_t site : c.sites) { + int32_t v; + memcpy(&v, reinterpret_cast(site), 4); + int64_t nv = static_cast(v) + delta; + if (nv < INT32_MIN || nv > INT32_MAX) { ok = false; break; } + } + if (!ok) { + memcpy(reinterpret_cast(c.shim_addr), reinterpret_cast(c.module_addr), c.shim_sym->size); + out.copied++; + out.warnings.push_back(name + ": a reference does not reach the module's copy; copied once instead, restart for exact state"); + continue; + } + for (uintptr_t site : c.sites) { + int32_t v; + memcpy(&v, reinterpret_cast(site), 4); + int32_t nv = static_cast(static_cast(v) + delta); + if (!hook::write_code(site, &nv, 4, err)) { out.warnings.push_back(name + ": " + err); ok = false; break; } + } + if (!ok) continue; + out.shared++; + log.debug("statics: %s shared, %zu site(s) now read 0x%lx", name.c_str(), c.sites.size(), static_cast(c.module_addr)); + } + if (out.copied && !in.in_pc32_range) + out.warnings.push_back(std::to_string(out.copied) + " static(s) copied once because the shim is out of PC32 range of the module; state diverges from here, restart for exact state"); + log.debug("statics: %u shared, %u copied, %u new, %u site(s), %u in static init left alone", out.shared, out.copied, fresh, sites, init_sites); + return true; +} + +} // namespace hr diff --git a/src/game/shared/neo/hotreload/vendor/src/hr_statics.h b/src/game/shared/neo/hotreload/vendor/src/hr_statics.h new file mode 100644 index 000000000..7e0c33572 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/hr_statics.h @@ -0,0 +1,46 @@ +// Static sharing. New code in the shim references its own copies +// of file-scope and function-local statics through 32-bit PC-relative +// displacements. For every such static that also exists in the original module +// the loader rewrites those displacements to the original's copy, so state is +// shared. Out of PC32 range: one-time byte copy plus a warning. +// +// The shim carries everything needed: the sidecar links it with +// -Wl,--emit-relocs -Wl,--unique=.data.* -Wl,--unique=.bss.*, so its .rela.text +// survives and every static sits in its own output section whose address names +// it through the shim's .symtab (with STT_FILE scoping, like functions). The +// new displacement is the old one plus (original address minus shim address): +// no instruction decoding, no object files. +#ifndef NTRE_HR_STATICS_H +#define NTRE_HR_STATICS_H + +#include +#include +#include +#include + +#include "hr_elf.h" +#include "hr_log.h" + +namespace hr { + +struct StaticShareInput { + const elf::File* shim = nullptr; // mapped shim file + uintptr_t shim_bias = 0; // l_addr of the shim + const std::vector* shim_symbols = nullptr; // the shim's .symtab + uintptr_t module_base = 0; // load bias of the module + const std::vector* module_symbols = nullptr; + const std::map>* module_objects = nullptr; // name -> module_symbols indexes (STT_OBJECT) + bool in_pc32_range = false; // shim within reach of the module +}; + +struct StaticShareOutcome { + uint32_t shared = 0; // statics whose references now point at the module's copy + uint32_t copied = 0; // statics whose shim copy received the module's bytes once + std::vector warnings; +}; + +bool share_statics(const StaticShareInput& in, StaticShareOutcome& out, const Logger& log); + +} // namespace hr + +#endif diff --git a/src/game/shared/neo/hotreload/vendor/src/ntre_hr.cpp b/src/game/shared/neo/hotreload/vendor/src/ntre_hr.cpp new file mode 100644 index 000000000..9c7280647 --- /dev/null +++ b/src/game/shared/neo/hotreload/vendor/src/ntre_hr.cpp @@ -0,0 +1,375 @@ +// Public API implementation: init, poll, apply, status, shutdown. +#include "ntre_hr.h" + +#include + +#include + +#include "hr_apply.h" +#include "hr_context.h" +#include "hr_paths.h" +#include "ntre_hr_protocol.h" + +#define NTRE_HR_CORE_VERSION "0.2.0" +#define NTRE_HR_STRINGIFY_(x) #x +#define NTRE_HR_STRINGIFY(x) NTRE_HR_STRINGIFY_(x) + +namespace { + +using hr::json::Value; + +Value build_state_json(const ntre_hr& hr) { + Value st = Value::object(); + st.set("protocol", Value::integer(NTRE_HR_PROTOCOL_VERSION)); + st.set("build_id", Value::string(hr.build_id)); + st.set("pid", Value::integer(static_cast(getpid()))); + st.set("module", Value::string(hr.module_name)); + st.set("module_path", Value::string(hr.mailbox.rel(hr.module_path))); + if (hr.region.reserved) { + Value r = Value::object(); + r.set("base", Value::string(hr::hex_address(hr.region.base))); + r.set("slot_size", Value::integer(static_cast(hr.region.slot_size))); + r.set("slot_count", Value::integer(hr.region.slot_count)); + r.set("next_slot", Value::integer(hr.region.next_slot)); + r.set("in_range", Value::boolean(hr.region.in_range)); + st.set("region", r); + } else { + st.set("region", Value::null()); + } + st.set("module_base", Value::string(hr::hex_address(hr.module_base))); + st.set("auto_apply", Value::boolean(hr.auto_apply)); + st.set("applied_seq", Value::integer(hr.applied_seq)); + st.set("heartbeat", Value::integer(static_cast(hr.heartbeat))); + return st; +} + +Value build_result_json(const ntre_hr& hr, const hr::Manifest& m, const hr::ApplyOutcome& o) { + Value r = Value::object(); + r.set("protocol", Value::integer(NTRE_HR_PROTOCOL_VERSION)); + r.set("build_id", Value::string(hr.build_id)); + r.set("module", Value::string(hr.module_name)); + r.set("seq", Value::integer(m.seq)); + r.set("status", Value::string(o.status)); + r.set("applied_unix_ms", Value::integer(hr::paths::now_unix_ms())); + Value c = Value::object(); + c.set("functions_hooked", Value::integer(o.counts.functions_hooked)); + c.set("statics_shared", Value::integer(o.counts.statics_shared)); + c.set("statics_copied", Value::integer(o.counts.statics_copied)); + c.set("globals_rebound", Value::integer(o.counts.globals_rebound)); + c.set("functions_skipped", Value::integer(o.counts.functions_skipped)); + r.set("counts", c); + Value w = Value::array(); + for (const std::string& s : o.warnings) w.push(Value::string(s)); + r.set("warnings", w); + r.set("error", o.error.empty() ? Value::null() : Value::string(o.error)); + return r; +} + +void write_state(ntre_hr& hr) { + hr.heartbeat++; + std::string err; + if (!hr.mailbox.write_json(hr.mailbox.state_path(hr.module_name), build_state_json(hr), err)) + hr.log.warn("state file: %s", err.c_str()); +} + +void refresh_sidecar(ntre_hr& hr) { + hr.sidecar = hr.mailbox.sidecar_presence(NTRE_HR_HEARTBEAT_STALE_SECONDS * 1000); + bool attached = hr.sidecar.present && hr.sidecar.fresh && hr.sidecar.protocol == NTRE_HR_PROTOCOL_VERSION; + if (hr.sidecar.present && hr.sidecar.fresh && hr.sidecar.protocol != NTRE_HR_PROTOCOL_VERSION && !hr.sidecar_attached) { + hr.log.warn("sidecar speaks protocol %lld, this module speaks %d: update the sidecar or the vendored loader", + static_cast(hr.sidecar.protocol), NTRE_HR_PROTOCOL_VERSION); + } + if (attached && !hr.sidecar.session.empty() && hr.sidecar.session != hr.sidecar_session) { + hr.sidecar_session = hr.sidecar.session; + hr.other_session_noted = false; + } + if (attached && !hr.sidecar_attached) { + hr.log.info("sidecar attached (pid %lld, %s)", static_cast(hr.sidecar.pid), hr.sidecar.auto_apply ? "mode save" : "mode trigger"); + hr.sidecar_ever_seen = true; + } else if (!attached && hr.sidecar_attached) { + hr.log.info("sidecar detached"); + } + hr.sidecar_attached = attached; +} + +// A manifest this process will never apply gets a "rejected" result right away, so the sidecar +// prints the reason instead of waiting for a result that never comes (protocol/README.md). +void reject(ntre_hr& hr, const hr::Manifest& m, const std::string& why, ntre_hr_log_level level) { + hr::ApplyOutcome o; + o.status = NTRE_HR_STATUS_REJECTED; + o.error = why; + std::string err; + if (!hr.mailbox.write_json(hr.mailbox.result_path(hr.module_name, m.seq), build_result_json(hr, m, o), err)) + hr.log.warn("result file: %s", err.c_str()); + hr.log.log(level, "rejected %s: %s", hr::manifest_file_name(hr.module_name, m.seq).c_str(), why.c_str()); +} + +// Read the manifests not handled yet and queue the ones that target this build. A manifest is +// known by its seq and mtime: sequence numbers restart per game process and a sidecar session +// may replace a file of the same seq, so a high-water mark would skip the new one. +void scan_manifests(ntre_hr& hr) { + uint32_t foreign = 0, other_session = 0; + std::map seen; + for (uint32_t seq : hr.mailbox.manifest_seqs(hr.module_name)) { + const int64_t stamp = hr::paths::mtime_ms(hr.mailbox.manifest_path(hr.module_name, seq)); + auto known = hr.seen_manifests.find(seq); + seen[seq] = stamp; + if (known != hr.seen_manifests.end() && known->second == stamp) continue; + hr::Manifest m; + std::string err; + if (!hr.mailbox.read_manifest(hr.module_name, seq, m, err)) { + m = hr::Manifest(); + m.seq = seq; + reject(hr, m, "manifest unreadable: " + err, NTRE_HR_LOG_WARN); + continue; + } + if (m.protocol != NTRE_HR_PROTOCOL_VERSION) { + reject(hr, m, + "protocol " + std::to_string(m.protocol) + ", this module speaks " + std::to_string(NTRE_HR_PROTOCOL_VERSION) + + ": update the sidecar or the vendored loader", + NTRE_HR_LOG_WARN); + continue; + } + if (!m.session.empty() && m.session != hr.sidecar_session) { + // Published by a sidecar session this process has not seen attached (an earlier one, + // or one whose presence file is not refreshed yet): read again next poll. + other_session++; + seen.erase(seq); + continue; + } + if (m.has_module_base && m.module_base != hr.module_base) { + // Linked for another game process: shims bind the module by address. The sidecar + // relinks the edits for this process when it attaches and removes these. + foreign++; + hr.log.debug("ignored %s: linked for the module at 0x%lx, this process has it at 0x%lx", m.shim.c_str(), + static_cast(m.module_base), static_cast(hr.module_base)); + continue; + } + if (m.build_id != hr.build_id) { + // Leftovers from an earlier build are expected after a rebuild; the sidecar prunes them. + reject(hr, m, "targets build " + m.build_id.substr(0, 12) + ", this process runs " + hr.build_id.substr(0, 12) + ": rebuild or restart the game", + NTRE_HR_LOG_DEBUG); + continue; + } + hr.pending.push_back(m); + hr.log.debug("queued %s", m.shim.c_str()); + } + hr.seen_manifests.swap(seen); + if (other_session && !hr.other_session_noted && hr.sidecar_attached) { + hr.other_session_noted = true; + hr.log.info("%u shim(s) in the mailbox came from another sidecar session and are ignored", other_session); + } + if (foreign && !hr.foreign_noted) { + hr.foreign_noted = true; + hr.log.info("%u shim(s) in the mailbox were linked for another game process and are ignored; the sidecar relinks your edits when it attaches", foreign); + } +} + +uint32_t apply_queue(ntre_hr& hr) { + uint32_t applied = 0; + while (!hr.pending.empty()) { + hr::Manifest m = hr.pending.front(); + hr.pending.pop_front(); + hr::ApplyOutcome o; + hr::apply_shim(hr, m, o); + std::string err; + if (!hr.mailbox.write_json(hr.mailbox.result_path(hr.module_name, m.seq), build_result_json(hr, m, o), err)) + hr.log.warn("result file: %s", err.c_str()); + if (strcmp(o.status, NTRE_HR_STATUS_APPLIED) == 0) { + applied++; + hr.applied_count++; + if (m.seq > hr.applied_seq) hr.applied_seq = m.seq; + hr.log.info("applied %s: %u hooked, %u statics shared, %u copied, %u globals rebound, %u skipped, %zu warning(s)", m.shim.c_str(), + o.counts.functions_hooked, o.counts.statics_shared, o.counts.statics_copied, o.counts.globals_rebound, + o.counts.functions_skipped, o.warnings.size()); + } else { + hr.log.error("%s %s: %s", o.status, m.shim.c_str(), o.error.c_str()); + } + for (const std::string& w : o.warnings) hr.log.warn("%s: %s", m.shim.c_str(), w.c_str()); + } + if (applied) write_state(hr); + return applied; +} + +// First hint for the region: where shims already in the mailbox expect to live (replay on attach). +uintptr_t preferred_region_base(const ntre_hr& hr, uint64_t slot_size) { + for (uint32_t seq : hr.mailbox.manifest_seqs(hr.module_name)) { + hr::Manifest m; + std::string err; + if (!hr.mailbox.read_manifest(hr.module_name, seq, m, err)) continue; + if (m.build_id != hr.build_id || !m.has_link_base || !m.has_slot) continue; + if (m.link_base < m.slot * slot_size) continue; + return m.link_base - m.slot * slot_size; + } + return 0; +} + +} // namespace + +extern "C" { + +void ntre_hr_config_init(ntre_hr_config* cfg) { + if (!cfg) return; + memset(cfg, 0, sizeof *cfg); + cfg->struct_size = sizeof *cfg; + cfg->auto_apply = true; + cfg->poll_interval_ms = 250; + cfg->heartbeat_interval_ms = 1000; +} + +ntre_hr* ntre_hr_init(const ntre_hr_config* cfg) { + hr::Logger boot; + if (!cfg || cfg->struct_size < sizeof(ntre_hr_config)) { boot.error("init: bad config (call ntre_hr_config_init first)"); return nullptr; } + boot.fn = cfg->log; + boot.user = cfg->log_user; + boot.verbose = cfg->verbose; + if (!cfg->module_name || !*cfg->module_name || !cfg->module_anchor || !cfg->build_dir || !*cfg->build_dir) { + boot.error("init: module_name, module_anchor and build_dir are required"); + return nullptr; + } + + ntre_hr* hr = new ntre_hr(); + hr->log = boot; + hr->module_name = cfg->module_name; + hr->module_anchor = cfg->module_anchor; + hr->pre_apply = cfg->pre_apply; + hr->post_apply = cfg->post_apply; + hr->apply_user = cfg->apply_user; + hr->auto_apply = cfg->auto_apply; + if (cfg->poll_interval_ms) hr->poll_interval_ms = cfg->poll_interval_ms; + if (cfg->heartbeat_interval_ms) hr->heartbeat_interval_ms = cfg->heartbeat_interval_ms; + + std::string err; + if (!hr::paths::module_of(cfg->module_anchor, hr->module_path, hr->module_base, err)) { + hr->log.error("init: %s", err.c_str()); + delete hr; + return nullptr; + } + hr->module_dir = hr::paths::dirname(hr->module_path); + hr::elf::LoadedImage img; + if (!hr::elf::find_loaded_image(reinterpret_cast(cfg->module_anchor), img)) { + hr->log.error("init: could not find the loaded image of %s", hr->module_path.c_str()); + delete hr; + return nullptr; + } + hr->module_base = img.bias; + hr->module_lo = img.lo; + hr->module_hi = img.hi; + hr->build_id = img.build_id; + if (hr->build_id.empty()) { + hr->log.warn("init: %s has no GNU build-id (link with -Wl,--build-id); skew guard disabled", hr->module_path.c_str()); + } + std::string build_dir = hr::paths::is_absolute(cfg->build_dir) ? cfg->build_dir : hr::paths::join(hr->module_dir, cfg->build_dir); + build_dir = hr::paths::realpath_or(build_dir); + if (!hr->mailbox.init(build_dir, err)) { + hr->log.error("init: mailbox: %s", err.c_str()); + delete hr; + return nullptr; + } + + uint64_t slot_size = cfg->slot_size ? cfg->slot_size : NTRE_HR_DEFAULT_SLOT_SIZE; + uint32_t slot_count = cfg->slot_count ? cfg->slot_count : NTRE_HR_DEFAULT_SLOT_COUNT; + uintptr_t preferred = preferred_region_base(*hr, slot_size); + hr->region.reserve(hr->module_lo, hr->module_hi, slot_size, slot_count, preferred, hr->log); + + hr->log.info("%s: module %s build %s, mailbox %s, mode %s", hr->module_name.c_str(), hr->module_path.c_str(), + hr->build_id.empty() ? "unknown" : hr->build_id.substr(0, 12).c_str(), hr->mailbox.dir().c_str(), + hr->auto_apply ? "auto" : "trigger"); + + int64_t now = hr::paths::now_mono_ms(); + hr->last_heartbeat_ms = now; + write_state(*hr); + refresh_sidecar(*hr); + if (!hr->sidecar_attached) hr->log.info("no sidecar attached: run `make watch` in your build shell to enable hot reload"); + scan_manifests(*hr); + if (!hr->pending.empty()) hr->log.info("%zu shim(s) waiting in the mailbox", hr->pending.size()); + return hr; +} + +bool ntre_hr_poll(ntre_hr* hr) { + if (!hr) return false; + int64_t now = hr::paths::now_mono_ms(); + if (hr->last_poll_ms >= 0 && now - hr->last_poll_ms < hr->poll_interval_ms) return false; + hr->last_poll_ms = now; + if (now - hr->last_heartbeat_ms >= hr->heartbeat_interval_ms) { + hr->last_heartbeat_ms = now; + write_state(*hr); + refresh_sidecar(*hr); + } + scan_manifests(*hr); + if (!hr->auto_apply || hr->pending.empty()) return false; + return apply_queue(*hr) > 0; +} + +uint32_t ntre_hr_apply_pending(ntre_hr* hr) { + if (!hr) return 0; + scan_manifests(*hr); + if (hr->pending.empty()) return 0; + return apply_queue(*hr); +} + +void ntre_hr_set_auto_apply(ntre_hr* hr, bool on) { + if (!hr || hr->auto_apply == on) return; + hr->auto_apply = on; + write_state(*hr); +} + +void ntre_hr_set_verbose(ntre_hr* hr, bool on) { + if (hr) hr->log.verbose = on; +} + +bool ntre_hr_get_status(const ntre_hr* hr, ntre_hr_status* out) { + if (!hr || !out || out->struct_size < sizeof(ntre_hr_status)) return false; + uint32_t size = out->struct_size; + memset(out, 0, sizeof *out); + out->struct_size = size; + out->sidecar_attached = hr->sidecar_attached; + out->sidecar_seen_ms_ago = hr->sidecar.present ? hr->sidecar.age_ms : -1; + out->sidecar_auto_apply = hr->sidecar.auto_apply; + out->auto_apply = hr->auto_apply; + out->pending = static_cast(hr->pending.size()); + out->applied = hr->applied_count; + out->applied_seq = hr->applied_seq; + out->region_reserved = hr->region.reserved; + out->region_in_range = hr->region.in_range; + out->region_base = hr->region.base; + out->slot_size = hr->region.slot_size; + out->slot_count = hr->region.slot_count; + out->next_slot = hr->region.next_slot; + return true; +} + +const char* ntre_hr_mailbox_dir(const ntre_hr* hr) { return hr ? hr->mailbox.dir().c_str() : ""; } +const char* ntre_hr_build_id(const ntre_hr* hr) { return hr ? hr->build_id.c_str() : ""; } + +extern "C" void __cxa_finalize(void*); + +void ntre_hr_shutdown(ntre_hr* hr) { + if (!hr) return; + for (auto& kv : hr->hooks) { + std::string err; + if (!hr::hook::restore(kv.second, err)) hr->log.warn("shutdown: %s", err.c_str()); + } + hr->hooks.clear(); + hr->mailbox.remove(hr->mailbox.state_path(hr->module_name)); + hr->region.unreserve(); + // Shims stay mapped on purpose: their code and statics may still be referenced. Their + // static destructors run now, newest shim first, while the module and the engine are + // still alive; nothing of theirs is left for process exit, and the shims hold no + // dependency on the module, so the engine unloads it on its own schedule. + uint32_t finalized = 0; + for (size_t i = hr->shim_dso_handles.size(); i-- > 0;) { + if (!hr->shim_dso_handles[i]) continue; + __cxa_finalize(reinterpret_cast(hr->shim_dso_handles[i])); + finalized++; + } + hr->shim_dso_handles.clear(); + hr->log.info("%s: hot reload shut down (%u shim(s) applied, %u finalized)", hr->module_name.c_str(), hr->applied_count, finalized); + delete hr; +} + +const char* ntre_hr_version(void) { return "ntre_hr " NTRE_HR_CORE_VERSION " protocol " NTRE_HR_STRINGIFY(NTRE_HR_PROTOCOL_VERSION); } + +uint32_t ntre_hr_protocol_version(void) { return NTRE_HR_PROTOCOL_VERSION; } + +} // extern "C" diff --git a/src/version_script.linux.hotreload.txt b/src/version_script.linux.hotreload.txt new file mode 100644 index 000000000..dd9cf8945 --- /dev/null +++ b/src/version_script.linux.hotreload.txt @@ -0,0 +1,13 @@ +{ + global: *; + local: + extern "C++" { + std::*; + __cxxabi*; + __gcc*; + __gxx*; + __gnu_cxx*; + __cxa*; + __dynamic_cast + }; +}; diff --git a/tools/hotreload-default-visibility.py b/tools/hotreload-default-visibility.py new file mode 100755 index 000000000..ca9d625d6 --- /dev/null +++ b/tools/hotreload-default-visibility.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Copy a directory of prebuilt SDK libraries, clearing ELF symbol visibility. + +The shipped .a archives (tier2, tier3, particles, ...) were compiled with +hidden visibility, so their globals (mdlcache, materials, g_pParticleSystemMgr) +never reach the module's dynamic symbol table. That is fine for a normal build +and fatal for the hot reload preset: a shim rebuilt from a translation unit +that references one of them cannot link against the module. + +This script writes byte-identical copies of every library into the destination +directory, changing exactly one thing: for every GLOBAL or WEAK symbol whose +visibility is INTERNAL or HIDDEN, st_other is set to DEFAULT. No code bytes, +sizes or offsets change. Shared libraries (.so) are copied unchanged. Files +whose copy is already newer than the source are skipped. + +Usage: hotreload-default-visibility.py +""" +import os +import shutil +import struct +import sys + +AR_MAGIC = b"!\n" +ELF_MAGIC = b"\x7fELF" + + +def patch_elf_object(buf, base): + """Promote hidden/internal GLOBAL and WEAK symbols in one ELF object + inside bytearray buf at offset base. Returns the number patched.""" + if buf[base + 4] != 2: # ELFCLASS64 + return 0 + e_shoff = struct.unpack_from("> 4 + visibility = st_other & 3 + if binding in (1, 2) and visibility in (1, 2): # GLOBAL/WEAK, INTERNAL/HIDDEN + buf[sym + 5] = st_other & ~3 + patched += 1 + return patched + + +def patch_archive(src, dest): + buf = bytearray(open(src, "rb").read()) + if buf[: len(AR_MAGIC)] != AR_MAGIC: + raise SystemExit(f"{src}: not an ar archive") + patched = 0 + off = len(AR_MAGIC) + while off + 60 <= len(buf): + size = int(bytes(buf[off + 48 : off + 58]).decode().strip() or "0") + data = off + 60 + if buf[data : data + 4] == ELF_MAGIC: + patched += patch_elf_object(buf, data) + off = data + size + (size & 1) + open(dest, "wb").write(buf) + return patched + + +def main(): + if len(sys.argv) != 3: + raise SystemExit(__doc__) + src_dir, dest_dir = sys.argv[1], sys.argv[2] + os.makedirs(dest_dir, exist_ok=True) + total = 0 + for name in sorted(os.listdir(src_dir)): + src = os.path.join(src_dir, name) + dest = os.path.join(dest_dir, name) + if not os.path.isfile(src): + continue + if os.path.exists(dest) and os.path.getmtime(dest) >= os.path.getmtime(src): + continue + if name.endswith(".a"): + n = patch_archive(src, dest) + print(f"{name}: {n} symbols promoted to default visibility") + total += n + else: + shutil.copy2(src, dest) + print(f"{name}: copied") + print(f"done: {dest_dir} ({total} symbols promoted)") + + +if __name__ == "__main__": + main()