Add live-tunable GPS solver highway weight

This commit is contained in:
2026-06-26 23:33:20 -05:00
parent 3fa723c3cc
commit 92287bd8e1
12 changed files with 363 additions and 43 deletions
+46 -11
View File
@@ -2,6 +2,41 @@
Current date/time context: 2026-06-26, local timezone America/Chicago.
## 2026-06-26 Confirmed Solver-Cost Patch
- We have unequivocally found the player GPS route-cost lever.
- The crashing RED4ext detour on `0x40bb40` has been replaced by an inline
patch at the road-tail block `0x40bb98`. The patch preserves vanilla
`node+0x93` behavior, checks `node+0x88 & 0x4000` for `Highway`, and then
multiplies highway lane cost by a tunable float.
- This inline patch avoids the register-clobber crash from the C++ detour.
Static disassembly showed the optimized caller reusing volatile state after
calling the tiny vanilla multiplier helper.
- The proof build used highway multiplier `0.35` and visibly changed player
map routes. It also caused over-aggressive behavior, including a bad custom
pin hairpin around the city-center roundabout, proving the hook is real but
that `0.35` is too low.
- The vanilla A/B build disabled only the inline multiplier patch and restored
obviously different route choices. User confirmed the custom pin returned to
the straight city-center roundabout route, while the `0.35` build took the
wrong hairpin.
- Current installed build enables the inline patch with default highway
multiplier `0.80` and polls
`red4ext/plugins/EdgeWeightGPS/solver_weights.bin` every 500 ms. The file is
exactly one little-endian float32 highway multiplier.
- The inline patch is deliberately broad across the vanilla road-tail branch:
reaching `0x40bb98` already means vanilla selected road-style modes `0/2/4`,
so we do not hard-gate on `solver+0xc4 == 2`. This avoids creating a manual
drive/autodrive disparity. Pedestrian mode `1` uses a different vanilla
branch and is not changed by this road-tail patch.
- Preset writer:
`tools/write_solver_weights.py`. Preset binaries live in
`presets/solver_weights/`: `vanilla`, `mild`, `default`, `strong`,
`proof-highway-cheap`, and `highway-expensive`.
- Evidence logs:
`logs/EdgeWeightGPS_inline_highway035_changed_routes_1449.log` and
`logs/EdgeWeightGPS_vanilla_ab_2120.log`.
## 2026-06-26 Static Update
- Installed RED4ext state is safe: the game plugin directory contains
@@ -110,19 +145,19 @@ Important caveat:
- Older DLLs are preserved disabled as:
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll.disabled`
and `EdgeWeightGPS.dd1423b.dll.disabled`.
- Source defaults now enable the full async solver multiplier hook:
`kEnableGpsMultiplierHooks = true`.
- The current behavior patch is deliberately narrow and diagnostic:
`DetourGpsNodeMultiplier` hooks native `0x40bb40`, applies only when
`job+0xc4 == 2` (driving route mode), reads lane/node flags from
`node+0x88`, and multiplies vanilla node edge cost by highway `0.35`,
GPSOnly `1.10`, pavement `1.25`, and all other flags `1.0`.
- `0x40bb00` auxiliary/reverse multiplier is hooked only for signature logging;
it is not patched yet.
- Source defaults enable the inline road-tail patch:
`kEnableGpsNodeMultiplierInlinePatch = true`.
- Source defaults do not enable the old full-function detour:
`kEnableGpsMultiplierHooks = false`.
- Current behavior patch is the inline `0x40bb98` code-cave patch. It preserves
vanilla road cost except for highway lanes, whose cost is multiplied by the
current `solver_weights.bin` float. Installed default is `0.80`.
- `solver_weights.bin` lives beside the DLL and is hot-polled every 500 ms.
- The plugin log was truncated immediately before installing this build.
- Latest archived test log:
`logs/EdgeWeightGPS_spatial_inversion_no_route_change_1845.log`.
- Game is cold as of the latest user message.
`logs/EdgeWeightGPS_vanilla_ab_2120.log`.
- A cold restart is required after installing a new DLL; changing
`solver_weights.bin` after that is hot-reloaded.
## 2026-06-26 Full Async Solver Findings
+16 -6
View File
@@ -66,12 +66,22 @@ Current behavior:
Active build note:
- The currently installed build is disabled on disk:
`EdgeWeightGPS.dll.disabled`. There is no active `EdgeWeightGPS.dll` in the
game plugin directory.
- Source defaults are still read-only for route behavior. They disable provider
class multiplier overrides and the spatial edge-cost patch, while keeping GPS
query lifecycle tracing available for probe builds.
- The currently installed build is active as
`red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll`.
- Source defaults enable the stable inline solver-cost patch at `0x40bb98`
(`kEnableGpsNodeMultiplierInlinePatch = true`) and keep the old full-function
`0x40bb40` C++ detour disabled (`kEnableGpsMultiplierHooks = false`).
- The inline patch preserves vanilla road cost except for highway lanes in the
road-tail branch. Highway lanes are multiplied by the live value in
`solver_weights.bin`, defaulting to `0.80`.
- The patch is intentionally not hard-gated on `solver+0xc4 == 2`; the vanilla
branch already limits it to road-style modes `0/2/4`, which keeps manual
drive and autodrive-like callers from diverging while leaving pedestrian mode
`1` alone.
- `solver_weights.bin` lives beside the DLL and is exactly one little-endian
float32. The plugin polls it every 500 ms from the Running game-state update.
`tools/write_solver_weights.py` writes the installed file or generates preset
binaries under `presets/solver_weights/`.
- The async route-job hooks are split behind `kEnableGpsRouteJobLifecycleHooks`
and are disabled in the active probe; this keeps the log focused on
`0x70a42c` submissions, `0x7094b8` result records, and local
+44 -14
View File
@@ -980,32 +980,62 @@ the original symptom: the solver is weighted enough to avoid obviously invalid
surfaces, but it has no strong reason to stay on highway corridors for long
vehicle trips.
The current RED4ext prototype therefore hooks `0x40bb40` and only changes
driving-mode calls (`solver/job + 0xc4 == 2`). It reads flags from
`node+0x88`, leaves vanilla results intact for ordinary roads and unknown
surfaces, and applies this diagnostic multiplier set:
The first RED4ext prototype tried to hook `0x40bb40` as a normal C++ detour.
That found the correct function but crashed during load-time GPS warmup because
the optimized caller expects the tiny vanilla helper not to disturb volatile
register state used immediately afterward.
The stable prototype now patches inline at the road-tail block `0x40bb98`
instead. Reaching that block already means the vanilla function selected one of
the road-style modes `0/2/4`; pedestrian mode `1` uses a different branch. The
patch preserves vanilla `node+0x93` behavior, checks `node+0x88 & 0x4000`, and
applies an extra multiplier only for highway lanes.
The proof multiplier was:
```text
Highway 0.35
GPSOnly 1.10
Pavement 1.25
Other 1.00
```
`0x40bb00` is currently hooked only for signature logging. If the node
multiplier test moves routes but produces weird reverse/turnaround behavior,
the next step is to tune or gate the auxiliary multiplier as well.
Live A/B testing confirmed this is the decisive world-map route-cost lever.
With `0.35`, routes visibly changed and a custom pin around the city-center
roundabout took an obviously bad highway/ramp hairpin. With the inline patch
disabled, the same route returned to the straight vanilla path through the
roundabout.
The current installed build uses a default highway multiplier of `0.80` and
hot-reloads `solver_weights.bin` from the plugin directory. The file is exactly
one little-endian float32. `tools/write_solver_weights.py` writes the installed
file and can generate named presets:
```text
vanilla 1.00
mild 0.90
default 0.80
strong 0.70
proof-highway-cheap 0.35
highway-expensive 3.00
```
The patch intentionally does not hard-gate on `solver+0xc4 == 2`, because
autodrive-like local vehicle navigation may use another road-style mode. Since
the patch lives in the vanilla road-tail branch, it applies to road-style modes
`0/2/4` and still leaves pedestrian mode `1` alone.
`0x40bb00` is not patched yet. If tuned highway weights still produce
reverse/turnaround artifacts, inspect or patch that auxiliary multiplier so
reverse/special progress branches receive a compatible policy.
## Future Improvements
Better versions of this mod could:
- tune the `0x40bb40` solver multiplier after live A/B testing confirms visible
route changes
- tune the `0x40bb98` inline highway multiplier from the current `0.80` default
using live `solver_weights.bin` presets
- inspect and, if needed, patch `0x40bb00` so reverse/special progress branches
receive the same road-class policy as forward expansion
- add a tiny live-tuning file for the solver multipliers, similar to the earlier
`spatial_weights.bin`, once the hook is confirmed stable
- extend `solver_weights.bin` beyond one highway float only if GPSOnly/pavement
tuning becomes necessary
- replace the crashing `0x8d46cc` detour with a narrower inline patch or a
safer caller-side hook
- decode enough of the `VAND` graph to join navigation points to traffic lane
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
ヘフL?
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
fff?
@@ -0,0 +1 @@
33>
+1
View File
@@ -0,0 +1 @@
333?
Binary file not shown.
+142 -12
View File
@@ -102,7 +102,7 @@ constexpr bool kEnableVerboseMappinRouteState = false;
constexpr bool kEnableGpsMultiplierHooks = false;
constexpr bool kEnableGpsAuxMultiplierHook = false;
constexpr bool kEnableGpsMultiplierSignatureTrace = false;
constexpr bool kEnableGpsNodeMultiplierInlinePatch = false;
constexpr bool kEnableGpsNodeMultiplierInlinePatch = true;
constexpr bool kEnableGpsCostTablePatch = false;
constexpr bool kEnableGpsProviderClassPatch = false;
constexpr bool kEnableGpsProviderClassPatchTrace = false;
@@ -213,7 +213,7 @@ constexpr uint16_t kTrafficLaneFlagNoAiDriving = 0x2000;
constexpr uint16_t kTrafficLaneFlagHighway = 0x4000;
constexpr uint16_t kTrafficLaneFlagNoAutodrive = 0x8000;
constexpr uintptr_t kMinimumReadableAddress = 0x10000;
constexpr float kGpsSolverHighwayMultiplier = 0.35f;
constexpr float kGpsSolverHighwayMultiplierDefault = 0.80f;
constexpr float kGpsSolverGpsOnlyMultiplier = 1.10f;
constexpr float kGpsSolverPavementMultiplier = 1.25f;
constexpr size_t kGpsNodeMultiplierRoadTailPatchSize = 14;
@@ -226,6 +226,7 @@ constexpr float kGpsSpatialRoadMultiplier = 1.0f;
constexpr float kGpsSpatialGpsOnlyMultiplier = 1.20f;
constexpr float kGpsSpatialPavementMultiplier = 1.35f;
constexpr float kGpsSpatialUnknownMultiplier = 1.05f;
constexpr uint64_t kGpsSolverWeightsReloadIntervalMs = 500;
constexpr uint64_t kGpsSpatialWeightsReloadIntervalMs = 500;
struct GpsProviderClassOverride
@@ -327,10 +328,12 @@ uint32_t gGpsResolveTraceWindowSerial = 0;
std::array<uint32_t, 64> gGpsEdgeCostClassLogCounts{};
std::array<uint8_t, kGpsNodeMultiplierRoadTailPatchSize> gGpsNodeMultiplierRoadTailOriginal{};
void* gGpsNodeMultiplierRoadTailCave = nullptr;
uint8_t* gGpsNodeMultiplierRoadTailHighwayConstant = nullptr;
bool gGpsNodeMultiplierRoadTailPatchApplied = false;
std::mutex gGpsCostLogMutex;
std::mutex gGpsAsyncLogMutex;
std::mutex gGpsCostTablePatchMutex;
std::mutex gGpsSolverWeightsMutex;
uint64_t gLogStartTick = 0;
uint64_t gLogFrequency = 0;
void* gMappinSetTrackedTarget = nullptr;
@@ -569,9 +572,13 @@ std::array<std::atomic<float>, 5> gGpsSpatialMultipliers = {
kGpsSpatialPavementMultiplier,
kGpsSpatialUnknownMultiplier,
};
std::atomic<float> gGpsSolverHighwayMultiplier{kGpsSolverHighwayMultiplierDefault};
std::mutex gGpsSpatialWeightsMutex;
std::atomic<uint64_t> gGpsSolverWeightsNextReloadCheckMs = 0;
std::atomic<uint64_t> gGpsSpatialWeightsNextReloadCheckMs = 0;
std::filesystem::file_time_type gGpsSolverWeightsLastWriteTime{};
std::filesystem::file_time_type gGpsSpatialWeightsLastWriteTime{};
bool gGpsSolverWeightsFileKnown = false;
bool gGpsSpatialWeightsFileKnown = false;
uintptr_t GetImageBase();
@@ -598,6 +605,11 @@ std::filesystem::path GetSpatialWeightsPath()
return GetPluginDirectory() / L"spatial_weights.bin";
}
std::filesystem::path GetSolverWeightsPath()
{
return GetPluginDirectory() / L"solver_weights.bin";
}
uint64_t GetElapsedMs()
{
if (!gLogStartTick || !gLogFrequency)
@@ -652,6 +664,103 @@ void WriteFloat(uint8_t* aTarget, float aValue)
std::memcpy(aTarget, &aValue, sizeof(aValue));
}
bool IsUsableSolverMultiplier(float aValue)
{
return std::isfinite(aValue) && aValue > 0.0f && aValue < 20.0f;
}
void StoreGpsSolverHighwayMultiplier(float aValue)
{
gGpsSolverHighwayMultiplier.store(aValue, std::memory_order_relaxed);
if (gGpsNodeMultiplierRoadTailHighwayConstant)
{
WriteFloat(gGpsNodeMultiplierRoadTailHighwayConstant, aValue);
FlushInstructionCache(GetCurrentProcess(), gGpsNodeMultiplierRoadTailHighwayConstant, sizeof(aValue));
}
}
void LogGpsSolverHighwayMultiplier(std::string_view aPrefix, float aValue)
{
std::ostringstream line;
line << aPrefix << " highway=" << aValue;
LogRed4ext(line.str());
}
void MaybeReloadGpsSolverWeights(bool aForce = false)
{
if (!kEnableGpsNodeMultiplierInlinePatch)
{
return;
}
const auto nowMs = GetElapsedMs();
if (!aForce && nowMs < gGpsSolverWeightsNextReloadCheckMs.load(std::memory_order_relaxed))
{
return;
}
std::lock_guard lock(gGpsSolverWeightsMutex);
if (!aForce && nowMs < gGpsSolverWeightsNextReloadCheckMs.load(std::memory_order_relaxed))
{
return;
}
gGpsSolverWeightsNextReloadCheckMs.store(nowMs + kGpsSolverWeightsReloadIntervalMs, std::memory_order_relaxed);
const auto path = GetSolverWeightsPath();
std::error_code error;
const auto writeTime = std::filesystem::last_write_time(path, error);
if (error)
{
if (aForce || gGpsSolverWeightsFileKnown)
{
gGpsSolverWeightsFileKnown = false;
std::ostringstream line;
line << "solver weights file unavailable path=" << path.string()
<< " using highway=" << gGpsSolverHighwayMultiplier.load(std::memory_order_relaxed);
LogRed4ext(line.str());
}
return;
}
if (!aForce && gGpsSolverWeightsFileKnown && writeTime == gGpsSolverWeightsLastWriteTime)
{
return;
}
float highwayMultiplier = 0.0f;
std::ifstream input(path, std::ios::binary);
input.read(reinterpret_cast<char*>(&highwayMultiplier), static_cast<std::streamsize>(sizeof(highwayMultiplier)));
if (input.gcount() != static_cast<std::streamsize>(sizeof(highwayMultiplier)))
{
std::ostringstream line;
line << "solver weights reload skipped path=" << path.string() << " bytes=" << input.gcount()
<< " expected=" << sizeof(highwayMultiplier);
LogRed4ext(line.str());
gGpsSolverWeightsFileKnown = true;
gGpsSolverWeightsLastWriteTime = writeTime;
return;
}
if (!IsUsableSolverMultiplier(highwayMultiplier))
{
std::ostringstream line;
line << "solver weights reload skipped path=" << path.string() << " invalidHighway=" << highwayMultiplier;
LogRed4ext(line.str());
gGpsSolverWeightsFileKnown = true;
gGpsSolverWeightsLastWriteTime = writeTime;
return;
}
StoreGpsSolverHighwayMultiplier(highwayMultiplier);
gGpsSolverWeightsFileKnown = true;
gGpsSolverWeightsLastWriteTime = writeTime;
std::ostringstream line;
line << "solver weights reloaded path=" << path.string() << " highway=" << highwayMultiplier;
LogRed4ext(line.str());
}
bool InstallGpsNodeMultiplierInlinePatch()
{
if (!kEnableGpsNodeMultiplierInlinePatch || gGpsNodeMultiplierRoadTailPatchApplied)
@@ -659,7 +768,7 @@ bool InstallGpsNodeMultiplierInlinePatch()
return true;
}
constexpr size_t kCaveSize = 64;
constexpr size_t kCaveSize = 80;
auto* cave = static_cast<uint8_t*>(
VirtualAlloc(nullptr, kCaveSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
if (!cave)
@@ -668,8 +777,9 @@ bool InstallGpsNodeMultiplierInlinePatch()
return false;
}
// Replaces the road-tail at 0x40bb98. This preserves the vanilla node93
// multiplier, then applies the highway multiplier before returning.
// Replaces the road-tail at 0x40bb98. Reaching this block already means
// vanilla selected one of the road-style solver modes (0/2/4), so keep
// the change broad enough for manual GPS and autodrive-like callers.
size_t offset = 0;
const auto emit = [&](std::initializer_list<uint8_t> aBytes) {
for (auto byte : aBytes)
@@ -691,7 +801,7 @@ bool InstallGpsNodeMultiplierInlinePatch()
emit({0xF3, 0x0F, 0x59, 0x05, 0x00, 0x00, 0x00, 0x00}); // mulss xmm0, [rip+disp32]
emit({0xC3}); // ret
while (offset < 40)
while ((offset % alignof(float)) != 0)
{
cave[offset++] = 0x90;
}
@@ -700,7 +810,8 @@ bool InstallGpsNodeMultiplierInlinePatch()
WriteFloat(cave + offset, 1.1f);
offset += sizeof(float);
const auto highwayConstantOffset = offset;
WriteFloat(cave + offset, kGpsSolverHighwayMultiplier);
const auto highwayMultiplier = gGpsSolverHighwayMultiplier.load(std::memory_order_relaxed);
WriteFloat(cave + offset, highwayMultiplier);
offset += sizeof(float);
const auto caveAddress = reinterpret_cast<uintptr_t>(cave);
@@ -743,12 +854,13 @@ bool InstallGpsNodeMultiplierInlinePatch()
VirtualProtect(target, sizeof(patch), oldProtect, &ignoredProtect);
gGpsNodeMultiplierRoadTailCave = cave;
gGpsNodeMultiplierRoadTailHighwayConstant = cave + highwayConstantOffset;
gGpsNodeMultiplierRoadTailPatchApplied = true;
std::ostringstream line;
line << "gps-node-multiplier-inline-patch applied target_rva=0x" << std::hex
<< kGpsNodeMultiplierRoadTailRva << " cave=" << static_cast<void*>(cave)
<< " highwayMultiplier=" << std::dec << kGpsSolverHighwayMultiplier;
<< " highwayMultiplier=" << std::dec << highwayMultiplier << " modes=road-tail";
LogRed4ext(line.str());
return true;
}
@@ -782,6 +894,7 @@ void RemoveGpsNodeMultiplierInlinePatch()
gGpsNodeMultiplierRoadTailCave = nullptr;
}
gGpsNodeMultiplierRoadTailHighwayConstant = nullptr;
gGpsNodeMultiplierRoadTailPatchApplied = false;
}
@@ -2338,16 +2451,21 @@ bool TryReadUint32FieldValue(void* aObject, uintptr_t aOffset, uint32_t& aValue)
aValue);
}
bool IsGpsRoadStyleMode(uint32_t aMode)
{
return aMode == 0 || aMode == 2 || aMode == 4;
}
bool IsDrivingRouteJob(void* aRouteSystem, void* aActiveEntry)
{
uint32_t mode = 0;
if (TryReadUint32FieldValue(aActiveEntry, 0x80, mode) && mode == 2)
if (TryReadUint32FieldValue(aActiveEntry, 0x80, mode) && IsGpsRoadStyleMode(mode))
{
return true;
}
auto* job = ReadPointerField(aRouteSystem, 0x90D0);
return TryReadUint32FieldValue(job, 0xC4, mode) && mode == 2;
return TryReadUint32FieldValue(job, 0xC4, mode) && IsGpsRoadStyleMode(mode);
}
void AppendFloatArray(std::ostringstream& aLine, const char* aLabel, const float* aValues, size_t aCount)
@@ -4339,7 +4457,7 @@ float GpsSolverNodeWeightMultiplier(uint16_t aFlags)
{
if ((aFlags & kTrafficLaneFlagHighway) != 0)
{
return kGpsSolverHighwayMultiplier;
return gGpsSolverHighwayMultiplier.load(std::memory_order_relaxed);
}
if ((aFlags & kTrafficLaneFlagGpsOnly) != 0)
{
@@ -5084,7 +5202,7 @@ float DetourGpsNodeMultiplier(void* aJob, void* aNode)
result = ReadGpsSolverNonRoadMultiplier(retry);
}
if (mode == 2 && std::isfinite(result))
if (IsGpsRoadStyleMode(mode) && std::isfinite(result))
{
result *= GpsSolverNodeWeightMultiplier(nodeFlags);
}
@@ -5352,6 +5470,8 @@ bool OnRunningUpdate(void* aApp)
++gUpdateLogCount;
}
MaybeReloadGpsSolverWeights();
return false;
}
@@ -5420,6 +5540,16 @@ RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4e
LogGpsSpatialMultipliers("spatial weights defaults", values);
}
if (kEnableGpsNodeMultiplierInlinePatch)
{
std::ostringstream line;
line << "solver weights path=" << GetSolverWeightsPath().string();
LogRed4ext(line.str());
MaybeReloadGpsSolverWeights(true);
LogGpsSolverHighwayMultiplier("solver weights active",
gGpsSolverHighwayMultiplier.load(std::memory_order_relaxed));
}
InstallGpsNodeMultiplierInlinePatch();
if (kEnableGpsQueryLifecycleHooks)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Write EdgeWeightGPS solver highway presets as one raw float32 value."""
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/solver_weights.bin"
)
PRESETS: dict[str, float] = {
"vanilla": 1.00,
"default": 0.80,
"mild": 0.90,
"strong": 0.70,
"proof-highway-cheap": 0.35,
"highway-expensive": 3.00,
}
def parse_multiplier(text: str) -> float:
value = float(text)
if not 0.0 < value < 20.0:
raise argparse.ArgumentTypeError("multiplier must be greater than 0 and less than 20")
return value
def write_weight(path: Path, value: float) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(struct.pack("<f", value))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("preset", nargs="?", choices=sorted(PRESETS), default="default")
parser.add_argument("--value", type=parse_multiplier, help="explicit highway multiplier")
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, value in PRESETS.items():
path = args.preset_dir / f"{name}.bin"
write_weight(path, value)
print(f"{path}: {value}")
return 0
value = args.value if args.value is not None else PRESETS[args.preset]
write_weight(args.output, value)
print(f"{args.output}: {value}")
return 0
if __name__ == "__main__":
raise SystemExit(main())