From 9966cf8272af9316397a2e7caf6d30877c6b6efc Mon Sep 17 00:00:00 2001 From: Jacob Babor Date: Fri, 26 Jun 2026 21:34:17 -0500 Subject: [PATCH] Add live spatial weight tuning --- docs/compaction-handoff.md | 9 ++ docs/red4ext-logging-shim.md | 9 ++ red4ext/EdgeWeightGPS/src/Main.cpp | 150 +++++++++++++++++++++++++++-- tools/write_spatial_weights.py | 58 +++++++++++ 4 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 tools/write_spatial_weights.py diff --git a/docs/compaction-handoff.md b/docs/compaction-handoff.md index 183e004..a14e93f 100644 --- a/docs/compaction-handoff.md +++ b/docs/compaction-handoff.md @@ -71,6 +71,15 @@ Current date/time context: 2026-06-26, local timezone America/Chicago. `toolbox run -c 2077 tools/build_red4ext_shim.sh`. - The resulting DLL remains uninstalled. Game install still has only `EdgeWeightGPS.dll.disabled`, not an active plugin DLL. +- Follow-up live-tuning support adds + `red4ext/plugins/EdgeWeightGPS/spatial_weights.bin`: five little-endian + float32 values in highway, road, GPSOnly, pavement, unknown order. The plugin + polls this file on route-producer entry, so changing it and replotting a route + applies new weights without rebuild/relaunch. +- `tools/write_spatial_weights.py` can write the active file or generate + presets. Generated presets live in `work/spatial-weights/`: + `vanilla.bin`, `current.bin`, `highway-free.bin`, + `highway-expensive.bin`, and `surface-extreme.bin`. Important caveat: diff --git a/docs/red4ext-logging-shim.md b/docs/red4ext-logging-shim.md index 0785c9b..2ea4af1 100644 --- a/docs/red4ext-logging-shim.md +++ b/docs/red4ext-logging-shim.md @@ -81,6 +81,15 @@ Active build note: `GPSEdgeCost` (`0x44f838`), samples a generated traffic-lane spatial grid using the search-state coordinates, and applies highway/road/pavement multipliers to returned edge costs. +- When the spatial patch is enabled, the plugin hot-reloads + `spatial_weights.bin` from the same directory as the DLL. The file is exactly + five little-endian float32 values in this order: + `highway`, `road`, `GPSOnly`, `pavement`, `unknown`. +- `tools/write_spatial_weights.py` writes that file directly, or can generate + named preset files under `work/spatial-weights/` for live copy-over testing. + The prepared presets are: + `vanilla`, `current`, `highway-free`, `highway-expensive`, and + `surface-extreme`. - The old verbose local-search probe is still available behind `kEnableGpsLocalSearchHooks`. It hooks `GPSSearch` (`0x44f054`), `GPSResolveHandle` (`0x44e1a8`), `GPSEdgeCost` (`0x44f838`), and the two diff --git a/red4ext/EdgeWeightGPS/src/Main.cpp b/red4ext/EdgeWeightGPS/src/Main.cpp index a01d5a9..4785b9e 100644 --- a/red4ext/EdgeWeightGPS/src/Main.cpp +++ b/red4ext/EdgeWeightGPS/src/Main.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -211,6 +212,7 @@ constexpr float kGpsSpatialRoadMultiplier = 1.0f; constexpr float kGpsSpatialGpsOnlyMultiplier = 1.20f; constexpr float kGpsSpatialPavementMultiplier = 1.35f; constexpr float kGpsSpatialUnknownMultiplier = 1.05f; +constexpr uint64_t kGpsSpatialWeightsReloadIntervalMs = 500; struct GpsProviderClassOverride { @@ -530,17 +532,38 @@ GpsProviderFilterFunc gOriginalGpsFilteredProviderFilter = nullptr; GpsProviderFilterFunc gOriginalGpsBaseProviderFilter = nullptr; GpsMultiplierFunc gOriginalGpsAuxMultiplier = nullptr; GpsMultiplierFunc gOriginalGpsNodeMultiplier = nullptr; +std::array, 5> gGpsSpatialMultipliers = { + kGpsSpatialHighwayMultiplier, + kGpsSpatialRoadMultiplier, + kGpsSpatialGpsOnlyMultiplier, + kGpsSpatialPavementMultiplier, + kGpsSpatialUnknownMultiplier, +}; +std::mutex gGpsSpatialWeightsMutex; +std::atomic gGpsSpatialWeightsNextReloadCheckMs = 0; +std::filesystem::file_time_type gGpsSpatialWeightsLastWriteTime{}; +bool gGpsSpatialWeightsFileKnown = false; -std::filesystem::path GetLogPath() +std::filesystem::path GetPluginDirectory() { wchar_t modulePath[MAX_PATH]{}; if (gModule && GetModuleFileNameW(gModule, modulePath, MAX_PATH) > 0) { auto path = std::filesystem::path(modulePath); - return path.parent_path() / L"EdgeWeightGPS.log"; + return path.parent_path(); } - return std::filesystem::path(L"red4ext") / L"plugins" / L"EdgeWeightGPS" / L"EdgeWeightGPS.log"; + return std::filesystem::path(L"red4ext") / L"plugins" / L"EdgeWeightGPS"; +} + +std::filesystem::path GetLogPath() +{ + return GetPluginDirectory() / L"EdgeWeightGPS.log"; +} + +std::filesystem::path GetSpatialWeightsPath() +{ + return GetPluginDirectory() / L"spatial_weights.bin"; } uint64_t GetElapsedMs() @@ -587,6 +610,100 @@ void LogRed4ext(std::string_view aMessage) } } +bool IsUsableSpatialMultiplier(float aValue) +{ + return std::isfinite(aValue) && aValue > 0.0f && aValue < 20.0f; +} + +void StoreGpsSpatialMultipliers(const std::array& aValues) +{ + for (size_t index = 0; index < aValues.size(); ++index) + { + gGpsSpatialMultipliers[index].store(aValues[index], std::memory_order_relaxed); + } +} + +void LogGpsSpatialMultipliers(std::string_view aPrefix, const std::array& aValues) +{ + std::ostringstream line; + line << aPrefix << " highway=" << aValues[0] << " road=" << aValues[1] << " gpsonly=" << aValues[2] + << " pavement=" << aValues[3] << " unknown=" << aValues[4]; + LogRed4ext(line.str()); +} + +void MaybeReloadGpsSpatialWeights() +{ + if (!kEnableGpsSpatialEdgeWeightPatch) + { + return; + } + + const auto nowMs = GetElapsedMs(); + if (nowMs < gGpsSpatialWeightsNextReloadCheckMs.load(std::memory_order_relaxed)) + { + return; + } + + std::lock_guard lock(gGpsSpatialWeightsMutex); + if (nowMs < gGpsSpatialWeightsNextReloadCheckMs.load(std::memory_order_relaxed)) + { + return; + } + gGpsSpatialWeightsNextReloadCheckMs.store(nowMs + kGpsSpatialWeightsReloadIntervalMs, std::memory_order_relaxed); + + const auto path = GetSpatialWeightsPath(); + std::error_code error; + const auto writeTime = std::filesystem::last_write_time(path, error); + if (error) + { + if (gGpsSpatialWeightsFileKnown) + { + gGpsSpatialWeightsFileKnown = false; + std::ostringstream line; + line << "spatial weights file unavailable path=" << path.string() << " using current/default values"; + LogRed4ext(line.str()); + } + return; + } + + if (gGpsSpatialWeightsFileKnown && writeTime == gGpsSpatialWeightsLastWriteTime) + { + return; + } + + std::array values{}; + std::ifstream input(path, std::ios::binary); + input.read(reinterpret_cast(values.data()), static_cast(values.size() * sizeof(float))); + if (input.gcount() != static_cast(values.size() * sizeof(float))) + { + std::ostringstream line; + line << "spatial weights reload skipped path=" << path.string() << " bytes=" << input.gcount() + << " expected=" << values.size() * sizeof(float); + LogRed4ext(line.str()); + gGpsSpatialWeightsFileKnown = true; + gGpsSpatialWeightsLastWriteTime = writeTime; + return; + } + + for (const auto value : values) + { + if (!IsUsableSpatialMultiplier(value)) + { + std::ostringstream line; + line << "spatial weights reload skipped path=" << path.string() << " invalidValue=" << value; + LogRed4ext(line.str()); + gGpsSpatialWeightsFileKnown = true; + gGpsSpatialWeightsLastWriteTime = writeTime; + return; + } + } + + StoreGpsSpatialMultipliers(values); + gGpsSpatialWeightsFileKnown = true; + gGpsSpatialWeightsLastWriteTime = writeTime; + LogGpsSpatialMultipliers("spatial weights reloaded", values); +} + uintptr_t GetImageBase() { return reinterpret_cast(GetModuleHandleW(nullptr)); @@ -1035,15 +1152,15 @@ float GpsSpatialRoadClassMultiplier(char aCode) switch (aCode) { case 'H': - return kGpsSpatialHighwayMultiplier; + return gGpsSpatialMultipliers[0].load(std::memory_order_relaxed); case 'R': - return kGpsSpatialRoadMultiplier; + return gGpsSpatialMultipliers[1].load(std::memory_order_relaxed); case 'G': - return kGpsSpatialGpsOnlyMultiplier; + return gGpsSpatialMultipliers[2].load(std::memory_order_relaxed); case 'P': - return kGpsSpatialPavementMultiplier; + return gGpsSpatialMultipliers[3].load(std::memory_order_relaxed); default: - return kGpsSpatialUnknownMultiplier; + return gGpsSpatialMultipliers[4].load(std::memory_order_relaxed); } } @@ -4213,6 +4330,8 @@ int32_t DetourGpsRouteProducer(void* aGraph, void* aQuery, void* aOutResult) LogRed4ext(line.str()); } + MaybeReloadGpsSpatialWeights(); + GpsEdgeCostStats stats{}; auto* previousStats = gActiveGpsEdgeCostStats; const auto previousRouteProducerCall = gActiveGpsRouteProducerCall; @@ -4905,6 +5024,21 @@ RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4e const auto added = aSdk->gameStates->Add(aHandle, kGameStateRunning, &gRunningState); LogRed4ext(added ? "registered Running game-state callbacks" : "failed to register Running game-state callbacks"); + if (kEnableGpsSpatialEdgeWeightPatch) + { + std::array values = { + gGpsSpatialMultipliers[0].load(std::memory_order_relaxed), + gGpsSpatialMultipliers[1].load(std::memory_order_relaxed), + gGpsSpatialMultipliers[2].load(std::memory_order_relaxed), + gGpsSpatialMultipliers[3].load(std::memory_order_relaxed), + gGpsSpatialMultipliers[4].load(std::memory_order_relaxed), + }; + std::ostringstream line; + line << "spatial weights path=" << GetSpatialWeightsPath().string(); + LogRed4ext(line.str()); + LogGpsSpatialMultipliers("spatial weights defaults", values); + } + if (kEnableGpsQueryLifecycleHooks) { AttachProbeHook("RunGPSQuery body 0x29bd128", kRunGpsQueryBodyRva, diff --git a/tools/write_spatial_weights.py b/tools/write_spatial_weights.py new file mode 100644 index 0000000..019f988 --- /dev/null +++ b/tools/write_spatial_weights.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Write EdgeWeightGPS spatial weight presets as five raw float32 values.""" + +from __future__ import annotations + +import argparse +import struct +from pathlib import Path + + +DEFAULT_INSTALL_PATH = Path( + "/var/home/salt/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/" + "Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/spatial_weights.bin" +) + +PRESETS: dict[str, tuple[float, float, float, float, float]] = { + "vanilla": (1.0, 1.0, 1.0, 1.0, 1.0), + "current": (0.62, 1.0, 1.20, 1.35, 1.05), + "highway-free": (0.25, 1.10, 1.35, 1.60, 1.15), + "highway-expensive": (3.0, 1.0, 1.0, 1.0, 1.0), + "surface-extreme": (0.45, 1.20, 1.80, 2.50, 1.30), +} + + +def parse_weights(text: str) -> tuple[float, float, float, float, float]: + values = tuple(float(part) for part in text.split(",")) + if len(values) != 5: + raise argparse.ArgumentTypeError("expected five comma-separated floats") + return values # type: ignore[return-value] + + +def write_weights(path: Path, values: tuple[float, float, float, float, float]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(struct.pack("<5f", *values)) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("preset", nargs="?", choices=sorted(PRESETS), default="current") + parser.add_argument("--weights", type=parse_weights, help="five comma-separated floats: H,R,G,P,U") + parser.add_argument("--output", type=Path, default=DEFAULT_INSTALL_PATH) + parser.add_argument("--preset-dir", type=Path, help="write every named preset into this directory") + args = parser.parse_args() + + if args.preset_dir: + for name, values in PRESETS.items(): + write_weights(args.preset_dir / f"{name}.bin", values) + print(f"{args.preset_dir / f'{name}.bin'}: {values}") + return 0 + + values = args.weights if args.weights is not None else PRESETS[args.preset] + write_weights(args.output, values) + print(f"{args.output}: {values}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())