Add live spatial weight tuning
This commit is contained in:
@@ -71,6 +71,15 @@ Current date/time context: 2026-06-26, local timezone America/Chicago.
|
|||||||
`toolbox run -c 2077 tools/build_red4ext_shim.sh`.
|
`toolbox run -c 2077 tools/build_red4ext_shim.sh`.
|
||||||
- The resulting DLL remains uninstalled. Game install still has only
|
- The resulting DLL remains uninstalled. Game install still has only
|
||||||
`EdgeWeightGPS.dll.disabled`, not an active plugin DLL.
|
`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:
|
Important caveat:
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ Active build note:
|
|||||||
`GPSEdgeCost` (`0x44f838`), samples a generated traffic-lane spatial grid
|
`GPSEdgeCost` (`0x44f838`), samples a generated traffic-lane spatial grid
|
||||||
using the search-state coordinates, and applies highway/road/pavement
|
using the search-state coordinates, and applies highway/road/pavement
|
||||||
multipliers to returned edge costs.
|
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
|
- The old verbose local-search probe is still available behind
|
||||||
`kEnableGpsLocalSearchHooks`. It hooks `GPSSearch` (`0x44f054`),
|
`kEnableGpsLocalSearchHooks`. It hooks `GPSSearch` (`0x44f054`),
|
||||||
`GPSResolveHandle` (`0x44e1a8`), `GPSEdgeCost` (`0x44f838`), and the two
|
`GPSResolveHandle` (`0x44e1a8`), `GPSEdgeCost` (`0x44f838`), and the two
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -211,6 +212,7 @@ constexpr float kGpsSpatialRoadMultiplier = 1.0f;
|
|||||||
constexpr float kGpsSpatialGpsOnlyMultiplier = 1.20f;
|
constexpr float kGpsSpatialGpsOnlyMultiplier = 1.20f;
|
||||||
constexpr float kGpsSpatialPavementMultiplier = 1.35f;
|
constexpr float kGpsSpatialPavementMultiplier = 1.35f;
|
||||||
constexpr float kGpsSpatialUnknownMultiplier = 1.05f;
|
constexpr float kGpsSpatialUnknownMultiplier = 1.05f;
|
||||||
|
constexpr uint64_t kGpsSpatialWeightsReloadIntervalMs = 500;
|
||||||
|
|
||||||
struct GpsProviderClassOverride
|
struct GpsProviderClassOverride
|
||||||
{
|
{
|
||||||
@@ -530,17 +532,38 @@ GpsProviderFilterFunc gOriginalGpsFilteredProviderFilter = nullptr;
|
|||||||
GpsProviderFilterFunc gOriginalGpsBaseProviderFilter = nullptr;
|
GpsProviderFilterFunc gOriginalGpsBaseProviderFilter = nullptr;
|
||||||
GpsMultiplierFunc gOriginalGpsAuxMultiplier = nullptr;
|
GpsMultiplierFunc gOriginalGpsAuxMultiplier = nullptr;
|
||||||
GpsMultiplierFunc gOriginalGpsNodeMultiplier = 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]{};
|
wchar_t modulePath[MAX_PATH]{};
|
||||||
if (gModule && GetModuleFileNameW(gModule, modulePath, MAX_PATH) > 0)
|
if (gModule && GetModuleFileNameW(gModule, modulePath, MAX_PATH) > 0)
|
||||||
{
|
{
|
||||||
auto path = std::filesystem::path(modulePath);
|
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()
|
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()
|
uintptr_t GetImageBase()
|
||||||
{
|
{
|
||||||
return reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
return reinterpret_cast<uintptr_t>(GetModuleHandleW(nullptr));
|
||||||
@@ -1035,15 +1152,15 @@ float GpsSpatialRoadClassMultiplier(char aCode)
|
|||||||
switch (aCode)
|
switch (aCode)
|
||||||
{
|
{
|
||||||
case 'H':
|
case 'H':
|
||||||
return kGpsSpatialHighwayMultiplier;
|
return gGpsSpatialMultipliers[0].load(std::memory_order_relaxed);
|
||||||
case 'R':
|
case 'R':
|
||||||
return kGpsSpatialRoadMultiplier;
|
return gGpsSpatialMultipliers[1].load(std::memory_order_relaxed);
|
||||||
case 'G':
|
case 'G':
|
||||||
return kGpsSpatialGpsOnlyMultiplier;
|
return gGpsSpatialMultipliers[2].load(std::memory_order_relaxed);
|
||||||
case 'P':
|
case 'P':
|
||||||
return kGpsSpatialPavementMultiplier;
|
return gGpsSpatialMultipliers[3].load(std::memory_order_relaxed);
|
||||||
default:
|
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());
|
LogRed4ext(line.str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MaybeReloadGpsSpatialWeights();
|
||||||
|
|
||||||
GpsEdgeCostStats stats{};
|
GpsEdgeCostStats stats{};
|
||||||
auto* previousStats = gActiveGpsEdgeCostStats;
|
auto* previousStats = gActiveGpsEdgeCostStats;
|
||||||
const auto previousRouteProducerCall = gActiveGpsRouteProducerCall;
|
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);
|
const auto added = aSdk->gameStates->Add(aHandle, kGameStateRunning, &gRunningState);
|
||||||
LogRed4ext(added ? "registered Running game-state callbacks" : "failed to register Running game-state callbacks");
|
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)
|
if (kEnableGpsQueryLifecycleHooks)
|
||||||
{
|
{
|
||||||
AttachProbeHook("RunGPSQuery body 0x29bd128", kRunGpsQueryBodyRva,
|
AttachProbeHook("RunGPSQuery body 0x29bd128", kRunGpsQueryBodyRva,
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user