Add live spatial weight tuning

This commit is contained in:
2026-06-26 21:34:17 -05:00
parent c525c1edf9
commit 9966cf8272
4 changed files with 218 additions and 8 deletions
+9
View File
@@ -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:
+9
View File
@@ -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
+142 -8
View File
@@ -10,6 +10,7 @@
#include <algorithm>
#include <array>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstring>
@@ -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<std::atomic<float>, 5> gGpsSpatialMultipliers = {
kGpsSpatialHighwayMultiplier,
kGpsSpatialRoadMultiplier,
kGpsSpatialGpsOnlyMultiplier,
kGpsSpatialPavementMultiplier,
kGpsSpatialUnknownMultiplier,
};
std::mutex gGpsSpatialWeightsMutex;
std::atomic<uint64_t> 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<float, 5>& 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<float, 5>& 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<float, 5> values{};
std::ifstream input(path, std::ios::binary);
input.read(reinterpret_cast<char*>(values.data()), static_cast<std::streamsize>(values.size() * sizeof(float)));
if (input.gcount() != static_cast<std::streamsize>(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<uintptr_t>(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<float, 5> 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,
+58
View File
@@ -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())