diff --git a/docs/compaction-handoff.md b/docs/compaction-handoff.md index 128c837..11dbe4e 100644 --- a/docs/compaction-handoff.md +++ b/docs/compaction-handoff.md @@ -2,6 +2,32 @@ Current date/time context: 2026-06-27, local timezone America/Chicago. +## 2026-06-27 Live Momentum Knob Installed + +- Installed a new telemetry/control DLL at about `03:21` local while the game was + cold. Installed size: `16899519` bytes. +- Active installed config: + `solver_weights.bin = 1.0` and `momentum_weights.bin = 8.0`. +- The plugin log beside the DLL was truncated to zero after install: + `red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.log`. +- `momentum_weights.bin` is one little-endian `float32`, hot-polled every + 500 ms. `0.0` disables the fixed ordinary-edge penalty; `8.0` is the current + prototype; larger values are stress tests. Presets are in + `presets/momentum/` and can be written live with + `python3 tools/write_momentum_weights.py `. +- New presets: + `vanilla=0.0`, `mild=4.0`, `default=8.0`, `strong=16.0`, + `silly=80.0`, `pathological=250.0`. +- The `gps-momentum-summary` line now waits for a nonzero result route-record + count before marking a query as summarized. It should report + `gpsResultRouteCount` directly instead of showing `0` from the staging fetch. +- Build verification passed: + `toolbox run -c 2077 ./tools/build_red4ext_shim.sh`. +- Next test should start with current `momentum=8.0`, then while the game is + open switch to `vanilla` and `silly` on one or two fixed routes to confirm + live A/B behavior. Good candidates from the last run: The Damned/Dogtown + pathological route and the SW Badlands fuel station route. + ## 2026-06-27 Momentum Telemetry Test Installed - Installed telemetry DLL at about `03:04` local while the game was cold. diff --git a/presets/momentum/default.bin b/presets/momentum/default.bin new file mode 100644 index 0000000..c53cb2f Binary files /dev/null and b/presets/momentum/default.bin differ diff --git a/presets/momentum/mild.bin b/presets/momentum/mild.bin new file mode 100644 index 0000000..d29fdc5 Binary files /dev/null and b/presets/momentum/mild.bin differ diff --git a/presets/momentum/pathological.bin b/presets/momentum/pathological.bin new file mode 100644 index 0000000..bf05e82 Binary files /dev/null and b/presets/momentum/pathological.bin differ diff --git a/presets/momentum/silly.bin b/presets/momentum/silly.bin new file mode 100644 index 0000000..b6c15d5 Binary files /dev/null and b/presets/momentum/silly.bin differ diff --git a/presets/momentum/strong.bin b/presets/momentum/strong.bin new file mode 100644 index 0000000..91ed970 Binary files /dev/null and b/presets/momentum/strong.bin differ diff --git a/presets/momentum/vanilla.bin b/presets/momentum/vanilla.bin new file mode 100644 index 0000000..593f470 Binary files /dev/null and b/presets/momentum/vanilla.bin differ diff --git a/red4ext/EdgeWeightGPS/src/Main.cpp b/red4ext/EdgeWeightGPS/src/Main.cpp index c95bdd5..6b5490d 100644 --- a/red4ext/EdgeWeightGPS/src/Main.cpp +++ b/red4ext/EdgeWeightGPS/src/Main.cpp @@ -224,7 +224,8 @@ constexpr float kGpsSolverPavementMultiplier = 1.25f; constexpr size_t kGpsNodeMultiplierRoadTailPatchSize = 14; constexpr size_t kGpsMomentumRelaxPatchSize = 23; constexpr size_t kGpsMomentumPenaltyCaveSize = 96; -constexpr float kGpsMomentumFixedEdgePenalty = 8.0f; +constexpr float kGpsMomentumFixedEdgePenaltyDefault = 8.0f; +constexpr float kGpsMomentumFixedEdgePenaltyMax = 1000.0f; constexpr std::array kGpsSolverNonRoadMultipliers = { 10.0f, 6.0f, 2.0f, 0.0f, 4.0f, 2.5f, 1.5f, 0.0f, }; @@ -236,6 +237,7 @@ constexpr float kGpsSpatialPavementMultiplier = 1.35f; constexpr float kGpsSpatialUnknownMultiplier = 1.05f; constexpr uint64_t kGpsSolverWeightsReloadIntervalMs = 500; constexpr uint64_t kGpsSpatialWeightsReloadIntervalMs = 500; +constexpr uint64_t kGpsMomentumWeightsReloadIntervalMs = 500; struct GpsProviderClassOverride { @@ -601,13 +603,18 @@ std::array, 5> gGpsSpatialMultipliers = { kGpsSpatialUnknownMultiplier, }; std::atomic gGpsSolverHighwayMultiplier{kGpsSolverHighwayMultiplierDefault}; +std::atomic gGpsMomentumFixedEdgePenalty{kGpsMomentumFixedEdgePenaltyDefault}; std::mutex gGpsSpatialWeightsMutex; +std::mutex gGpsMomentumWeightsMutex; std::atomic gGpsSolverWeightsNextReloadCheckMs = 0; std::atomic gGpsSpatialWeightsNextReloadCheckMs = 0; +std::atomic gGpsMomentumWeightsNextReloadCheckMs = 0; std::filesystem::file_time_type gGpsSolverWeightsLastWriteTime{}; std::filesystem::file_time_type gGpsSpatialWeightsLastWriteTime{}; +std::filesystem::file_time_type gGpsMomentumWeightsLastWriteTime{}; bool gGpsSolverWeightsFileKnown = false; bool gGpsSpatialWeightsFileKnown = false; +bool gGpsMomentumWeightsFileKnown = false; uintptr_t GetImageBase(); @@ -638,6 +645,11 @@ std::filesystem::path GetSolverWeightsPath() return GetPluginDirectory() / L"solver_weights.bin"; } +std::filesystem::path GetMomentumWeightsPath() +{ + return GetPluginDirectory() / L"momentum_weights.bin"; +} + uint64_t GetElapsedMs() { if (!gLogStartTick || !gLogFrequency) @@ -715,6 +727,34 @@ void LogGpsSolverHighwayMultiplier(std::string_view aPrefix, float aValue) LogRed4ext(line.str()); } +bool IsUsableMomentumPenalty(float aValue) +{ + return std::isfinite(aValue) && aValue >= 0.0f && aValue <= kGpsMomentumFixedEdgePenaltyMax; +} + +void StoreGpsMomentumFixedEdgePenalty(float aValue) +{ + gGpsMomentumFixedEdgePenalty.store(aValue, std::memory_order_relaxed); + + for (auto& site : gGpsMomentumPenaltyPatchSites) + { + if (!site.penaltyConstant) + { + continue; + } + + WriteFloat(site.penaltyConstant, aValue); + FlushInstructionCache(GetCurrentProcess(), site.penaltyConstant, sizeof(aValue)); + } +} + +void LogGpsMomentumFixedEdgePenalty(std::string_view aPrefix, float aValue) +{ + std::ostringstream line; + line << aPrefix << " fixedEdgePenalty=" << aValue; + LogRed4ext(line.str()); +} + void EmitAbsoluteRaxCall(uint8_t* aCave, size_t& aOffset, uintptr_t aTarget) { aCave[aOffset++] = 0x48; // mov rax, imm64 @@ -838,6 +878,82 @@ void MaybeReloadGpsSolverWeights(bool aForce = false) LogRed4ext(line.str()); } +void MaybeReloadGpsMomentumWeights(bool aForce = false) +{ + if (!kEnableGpsMomentumPenaltyInlinePatch) + { + return; + } + + const auto nowMs = GetElapsedMs(); + if (!aForce && nowMs < gGpsMomentumWeightsNextReloadCheckMs.load(std::memory_order_relaxed)) + { + return; + } + + std::lock_guard lock(gGpsMomentumWeightsMutex); + if (!aForce && nowMs < gGpsMomentumWeightsNextReloadCheckMs.load(std::memory_order_relaxed)) + { + return; + } + gGpsMomentumWeightsNextReloadCheckMs.store(nowMs + kGpsMomentumWeightsReloadIntervalMs, + std::memory_order_relaxed); + + const auto path = GetMomentumWeightsPath(); + std::error_code error; + const auto writeTime = std::filesystem::last_write_time(path, error); + if (error) + { + if (aForce || gGpsMomentumWeightsFileKnown) + { + gGpsMomentumWeightsFileKnown = false; + std::ostringstream line; + line << "momentum weights file unavailable path=" << path.string() + << " using fixedEdgePenalty=" << gGpsMomentumFixedEdgePenalty.load(std::memory_order_relaxed); + LogRed4ext(line.str()); + } + return; + } + + if (!aForce && gGpsMomentumWeightsFileKnown && writeTime == gGpsMomentumWeightsLastWriteTime) + { + return; + } + + float fixedEdgePenalty = 0.0f; + std::ifstream input(path, std::ios::binary); + input.read(reinterpret_cast(&fixedEdgePenalty), static_cast(sizeof(fixedEdgePenalty))); + if (input.gcount() != static_cast(sizeof(fixedEdgePenalty))) + { + std::ostringstream line; + line << "momentum weights reload skipped path=" << path.string() << " bytes=" << input.gcount() + << " expected=" << sizeof(fixedEdgePenalty); + LogRed4ext(line.str()); + gGpsMomentumWeightsFileKnown = true; + gGpsMomentumWeightsLastWriteTime = writeTime; + return; + } + + if (!IsUsableMomentumPenalty(fixedEdgePenalty)) + { + std::ostringstream line; + line << "momentum weights reload skipped path=" << path.string() + << " invalidFixedEdgePenalty=" << fixedEdgePenalty; + LogRed4ext(line.str()); + gGpsMomentumWeightsFileKnown = true; + gGpsMomentumWeightsLastWriteTime = writeTime; + return; + } + + StoreGpsMomentumFixedEdgePenalty(fixedEdgePenalty); + gGpsMomentumWeightsFileKnown = true; + gGpsMomentumWeightsLastWriteTime = writeTime; + + std::ostringstream line; + line << "momentum weights reloaded path=" << path.string() << " fixedEdgePenalty=" << fixedEdgePenalty; + LogRed4ext(line.str()); +} + void RemoveGpsMomentumPenaltyInlinePatch(); bool InstallGpsMomentumPenaltyPatchSite(GpsMomentumPenaltyPatchSite& aSite, @@ -889,7 +1005,7 @@ bool InstallGpsMomentumPenaltyPatchSite(GpsMomentumPenaltyPatchSite& aSite, } const auto penaltyConstantOffset = offset; - WriteFloat(cave + offset, kGpsMomentumFixedEdgePenalty); + WriteFloat(cave + offset, gGpsMomentumFixedEdgePenalty.load(std::memory_order_relaxed)); offset += sizeof(float); const auto caveAddress = reinterpret_cast(cave); @@ -925,7 +1041,7 @@ bool InstallGpsMomentumPenaltyPatchSite(GpsMomentumPenaltyPatchSite& aSite, std::ostringstream line; line << "gps-momentum-penalty-inline-patch applied site=" << aSite.name << " target_rva=0x" << std::hex << aSite.targetRva << " cave=" << static_cast(cave) << std::dec - << " fixedEdgePenalty=" << kGpsMomentumFixedEdgePenalty; + << " fixedEdgePenalty=" << gGpsMomentumFixedEdgePenalty.load(std::memory_order_relaxed); LogRed4ext(line.str()); return true; } @@ -2604,6 +2720,14 @@ void AppendGpsResultObjectLightCounts(std::ostringstream& aLine, const char* aPr aLine << " " << aPrefix << "_pointCount=" << pointCount; } +bool TryReadGpsResultRouteCount(void* aResultObject, uint32_t& aRouteCount) +{ + aRouteCount = 0; + return aResultObject && + TryReadMemory(reinterpret_cast(reinterpret_cast(aResultObject) + 0x34), + aRouteCount); +} + void AppendGpsResultElementArraySummary(std::ostringstream& aLine, const char* aPrefix, void* aResultObject) { uint32_t capacity = 0; @@ -4555,7 +4679,9 @@ bool DetourGpsQueryResultFetch(void* aManager, uint32_t aQueryId, void* aOutPath (!isTracked && callCount < kMaxUntrackedSamples && aQueryId < 2)) : (result && isMapRouteQuery); - if (result && isMapRouteQuery) + uint32_t gpsResultRouteCount = 0; + const auto hasGpsResultRouteCount = TryReadGpsResultRouteCount(aOutPath, gpsResultRouteCount); + if (result && isMapRouteQuery && hasGpsResultRouteCount && gpsResultRouteCount > 0) { TrackedGpsQuery summaryQuery{}; uint64_t callsA = 0; @@ -4566,14 +4692,15 @@ bool DetourGpsQueryResultFetch(void* aManager, uint32_t aQueryId, void* aOutPath globalCallsB)) { const auto totalCalls = callsA + callsB; - const auto fixedPenalty = kGpsMomentumFixedEdgePenalty; + const auto fixedPenalty = gGpsMomentumFixedEdgePenalty.load(std::memory_order_relaxed); std::ostringstream line; line << "gps-momentum-summary queryId=" << aQueryId << " resultFetchCall=" << perQueryCall; AppendTrackedGpsQuery(line, summaryQuery); line << " callsA=" << callsA << " callsB=" << callsB << " callsTotal=" << totalCalls << " fixedPenalty=" << fixedPenalty << " penaltyTotal=" << static_cast(totalCalls) * static_cast(fixedPenalty) - << " globalCallsA=" << globalCallsA << " globalCallsB=" << globalCallsB; + << " globalCallsA=" << globalCallsA << " globalCallsB=" << globalCallsB + << " gpsResultRouteCount=" << gpsResultRouteCount; AppendGpsResultObjectLightCounts(line, "gpsResult", aOutPath); LogRed4ext(line.str()); } @@ -5787,6 +5914,7 @@ bool OnRunningUpdate(void* aApp) } MaybeReloadGpsSolverWeights(); + MaybeReloadGpsMomentumWeights(); return false; } @@ -5869,9 +5997,11 @@ RED4EXT_C_EXPORT bool RED4EXT_CALL Main(RED4ext::v1::PluginHandle aHandle, RED4e if (kEnableGpsMomentumPenaltyInlinePatch) { std::ostringstream line; - line << "momentum fixed-edge penalty active value=" << kGpsMomentumFixedEdgePenalty - << " sites=ordinary-expansion"; + line << "momentum weights path=" << GetMomentumWeightsPath().string(); LogRed4ext(line.str()); + MaybeReloadGpsMomentumWeights(true); + LogGpsMomentumFixedEdgePenalty("momentum fixed-edge penalty active", + gGpsMomentumFixedEdgePenalty.load(std::memory_order_relaxed)); } InstallGpsNodeMultiplierInlinePatch(); diff --git a/tools/write_momentum_weights.py b/tools/write_momentum_weights.py new file mode 100755 index 0000000..a2b4216 --- /dev/null +++ b/tools/write_momentum_weights.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Write EdgeWeightGPS momentum penalty 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/momentum_weights.bin" +) + +PRESETS: dict[str, float] = { + "vanilla": 0.0, + "default": 8.0, + "mild": 4.0, + "strong": 16.0, + "silly": 80.0, + "pathological": 250.0, +} + + +def parse_penalty(text: str) -> float: + value = float(text) + if not 0.0 <= value <= 1000.0: + raise argparse.ArgumentTypeError("penalty must be between 0 and 1000") + return value + + +def write_weight(path: Path, value: float) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(struct.pack(" int: + parser = argparse.ArgumentParser() + parser.add_argument("preset", nargs="?", choices=sorted(PRESETS), default="default") + parser.add_argument("--value", type=parse_penalty, help="explicit fixed edge penalty") + 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())