Add traffic lane GPS weighting toolkit
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# 2077 Edge Weight Mod
|
||||
|
||||
Experimental Cyberpunk 2077 GPS route-weighting mod tooling.
|
||||
|
||||
## Feasibility
|
||||
|
||||
Cyberpunk exposes a native `gamegpsGPSSystem`, but the current public RTTI dump does
|
||||
not expose script-callable methods for replacing or overriding the GPS planner. The
|
||||
useful surface is the traffic lane resource data:
|
||||
|
||||
- `worldTrafficPersistentResource.data.lanes`
|
||||
- `worldTrafficLanePersistent.length`
|
||||
- `worldTrafficLanePersistent.maxSpeed`
|
||||
- `worldTrafficLanePersistent.flags`
|
||||
- `worldTrafficLanePersistent.playerGPSInfo`
|
||||
- `worldTrafficLanePersistentFlags.Highway`
|
||||
- `worldTrafficLanePersistentFlags.GPSOnly`
|
||||
|
||||
That strongly suggests the in-game GPS path is computed natively over the traffic
|
||||
lane graph, using lane metadata as edge cost inputs. A full replacement planner
|
||||
would require native hooks or patching the game executable. A feasible REDmod-style
|
||||
mod is to patch lane resources so highways and major roads have better effective
|
||||
costs than side streets.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
This repo currently contains a WolvenKit-export patcher:
|
||||
|
||||
```bash
|
||||
python3 tools/patch_traffic_lanes.py exported-json-dir patched-json-dir
|
||||
```
|
||||
|
||||
The patcher recursively scans WolvenKit JSON exports for `lanes` arrays that look
|
||||
like `worldTrafficLanePersistent` data and applies configurable speed weighting:
|
||||
|
||||
- highways get boosted toward a preferred GPS speed
|
||||
- regular roads get a moderate speed floor
|
||||
- pavement, crosswalk, and disabled traffic lanes are left alone
|
||||
- optional non-highway speed caps can make highways more attractive
|
||||
|
||||
After patching, deserialize the JSON back into CR2W and pack the resulting
|
||||
resources into an archive.
|
||||
|
||||
The toolbox now has WolvenKit CLI installed:
|
||||
|
||||
```bash
|
||||
toolbox run --container 2077 bash -lc '$HOME/.dotnet/tools/cp77tools --help'
|
||||
```
|
||||
|
||||
See [docs/tooling.md](docs/tooling.md) for the end-to-end archive build flow.
|
||||
|
||||
## Status
|
||||
|
||||
This is not yet a packaged playable mod because this workspace does not contain
|
||||
the Cyberpunk archive resources. The toolbox has the required CLI tooling now, so
|
||||
the next step is running the archive build script against a local Cyberpunk 2077
|
||||
install path.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Tooling
|
||||
|
||||
The Fedora toolbox container is expected to be named `2077`.
|
||||
|
||||
Installed inside that toolbox:
|
||||
|
||||
- .NET SDK 8, 9, and 10
|
||||
- GCC/G++, CMake, make
|
||||
- `jq`, `ripgrep`
|
||||
- WolvenKit CLI NuGet tool `wolvenkit.cli` 8.18.1
|
||||
|
||||
The WolvenKit command is:
|
||||
|
||||
```bash
|
||||
toolbox run --container 2077 bash -lc '$HOME/.dotnet/tools/cp77tools --help'
|
||||
```
|
||||
|
||||
## Discover Traffic Resources
|
||||
|
||||
```bash
|
||||
tools/discover_traffic_resources.sh \
|
||||
"/path/to/Cyberpunk 2077/archive/pc/content" \
|
||||
traffic-resources.txt
|
||||
```
|
||||
|
||||
This lists archive paths matching traffic/persistent/lane naming. The regex is
|
||||
intentionally broad because CDPR naming can vary between base game and DLC
|
||||
archives.
|
||||
|
||||
## Build Patch Archive
|
||||
|
||||
```bash
|
||||
tools/build_lane_weight_archive.sh \
|
||||
"/path/to/Cyberpunk 2077" \
|
||||
"/path/to/Cyberpunk 2077/archive/pc/content" \
|
||||
work/lane-weight-build
|
||||
```
|
||||
|
||||
The script performs:
|
||||
|
||||
1. `cp77tools extract` for traffic lane resources
|
||||
2. `cp77tools convert serialize` to JSON
|
||||
3. `tools/patch_traffic_lanes.py`
|
||||
4. `cp77tools convert deserialize` back to CR2W
|
||||
5. `cp77tools pack` into an archive
|
||||
|
||||
Copy the resulting archive from `work/lane-weight-build/05_packed` into
|
||||
`Cyberpunk 2077/archive/pc/mod` for manual testing.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Data": {
|
||||
"RootChunk": {
|
||||
"data": {
|
||||
"lanes": [
|
||||
{
|
||||
"flags": 16400,
|
||||
"maxSpeed": 55,
|
||||
"length": 240.0,
|
||||
"laneNumber": 1,
|
||||
"playerGPSInfo": {
|
||||
"subGraphId": 1,
|
||||
"stronglyConnectedComponentId": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"flags": 16,
|
||||
"maxSpeed": 25,
|
||||
"length": 80.0,
|
||||
"laneNumber": 2,
|
||||
"playerGPSInfo": {
|
||||
"subGraphId": 1,
|
||||
"stronglyConnectedComponentId": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"flags": 24,
|
||||
"maxSpeed": 10,
|
||||
"length": 25.0,
|
||||
"laneNumber": 3,
|
||||
"playerGPSInfo": {
|
||||
"subGraphId": 1,
|
||||
"stronglyConnectedComponentId": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOL = ROOT / "tools" / "patch_traffic_lanes.py"
|
||||
|
||||
|
||||
def test_patch_sample(tmp_path):
|
||||
output = tmp_path / "patched.json"
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(TOOL),
|
||||
str(ROOT / "samples" / "traffic_resource.json"),
|
||||
str(output),
|
||||
],
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
assert "lanes changed: 2" in result.stdout
|
||||
|
||||
patched = json.loads(output.read_text(encoding="utf-8"))
|
||||
lanes = patched["Data"]["RootChunk"]["data"]["lanes"]
|
||||
|
||||
assert lanes[0]["maxSpeed"] == 90
|
||||
assert lanes[1]["maxSpeed"] == 45
|
||||
assert lanes[2]["maxSpeed"] == 10
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 3 ]]; then
|
||||
echo "usage: $0 <game-dir> <archive-or-archive-dir> <work-dir>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
game_dir=$1
|
||||
archive_path=$2
|
||||
work_dir=$3
|
||||
container=${CP77_TOOLBOX_CONTAINER:-2077}
|
||||
tool='${HOME}/.dotnet/tools/cp77tools'
|
||||
|
||||
raw_dir="${work_dir}/01_raw"
|
||||
json_dir="${work_dir}/02_json"
|
||||
patched_json_dir="${work_dir}/03_json_patched"
|
||||
patched_raw_dir="${work_dir}/04_raw_patched"
|
||||
packed_dir="${work_dir}/05_packed"
|
||||
|
||||
mkdir -p "${raw_dir}" "${json_dir}" "${patched_json_dir}" "${patched_raw_dir}" "${packed_dir}"
|
||||
|
||||
toolbox run --container "${container}" bash -lc \
|
||||
"${tool} extract '$archive_path' --gamepath '$game_dir' --outpath '$raw_dir' --regex 'traffic.*persistent|persistent.*traffic|traffic.*lane|world.*traffic'"
|
||||
|
||||
toolbox run --container "${container}" bash -lc \
|
||||
"${tool} convert serialize '$raw_dir' --outpath '$json_dir'"
|
||||
|
||||
python3 tools/patch_traffic_lanes.py "${json_dir}" "${patched_json_dir}" --copy-unchanged
|
||||
|
||||
toolbox run --container "${container}" bash -lc \
|
||||
"${tool} convert deserialize '$patched_json_dir' --outpath '$patched_raw_dir'"
|
||||
|
||||
toolbox run --container "${container}" bash -lc \
|
||||
"${tool} pack '$patched_raw_dir' --outpath '$packed_dir'"
|
||||
|
||||
echo "packed archive output: ${packed_dir}"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
container="${CP77_TOOLBOX_CONTAINER:-2077}"
|
||||
tool='${HOME}/.dotnet/tools/cp77tools'
|
||||
|
||||
exec toolbox run --container "${container}" bash -lc "${tool} $*"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "usage: $0 <archive-file-or-dir> [output-list]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
archive_path=$1
|
||||
output=${2:-traffic-resources.txt}
|
||||
container=${CP77_TOOLBOX_CONTAINER:-2077}
|
||||
tool='${HOME}/.dotnet/tools/cp77tools'
|
||||
|
||||
toolbox run --container "${container}" bash -lc \
|
||||
"${tool} archive --list --regex 'traffic.*persistent|persistent.*traffic|traffic.*lane|world.*traffic' '$archive_path'" \
|
||||
> "${output}"
|
||||
|
||||
echo "wrote ${output}"
|
||||
Executable
+240
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch WolvenKit JSON exports of Cyberpunk traffic lane resources.
|
||||
|
||||
The game GPS planner appears to be native and data-driven over traffic lanes.
|
||||
This tool changes lane metadata that is likely to feed edge-cost selection:
|
||||
`maxSpeed` and, optionally, non-highway speed caps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
FLAGS = {
|
||||
"FromRoadSpline": 1,
|
||||
"Bidirectional": 2,
|
||||
"PatrolRoute": 4,
|
||||
"Pavement": 8,
|
||||
"Road": 16,
|
||||
"Intersection": 32,
|
||||
"NeverDeadEnd": 64,
|
||||
"TrafficDisabled": 128,
|
||||
"CrossWalk": 256,
|
||||
"GPSOnly": 512,
|
||||
"ShowDebug": 1024,
|
||||
"Blockade": 2048,
|
||||
"Yield": 4096,
|
||||
"NoAIDriving": 8192,
|
||||
"Highway": 16384,
|
||||
"NoAutodrive": 32768,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatchStats:
|
||||
files_seen: int = 0
|
||||
files_changed: int = 0
|
||||
lane_sets_seen: int = 0
|
||||
lanes_seen: int = 0
|
||||
lanes_changed: int = 0
|
||||
highway_lanes_changed: int = 0
|
||||
road_lanes_changed: int = 0
|
||||
capped_lanes_changed: int = 0
|
||||
|
||||
|
||||
def unwrap(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
for key in ("value", "Value", "$value", "_value"):
|
||||
if key in value and len(value) <= 3:
|
||||
return value[key]
|
||||
return value
|
||||
|
||||
|
||||
def set_wrapped(container: dict[str, Any], key: str, value: Any) -> None:
|
||||
current = container[key]
|
||||
if isinstance(current, dict):
|
||||
for value_key in ("value", "Value", "$value", "_value"):
|
||||
if value_key in current:
|
||||
current[value_key] = value
|
||||
return
|
||||
container[key] = value
|
||||
|
||||
|
||||
def as_int(value: Any) -> int | None:
|
||||
value = unwrap(value)
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(value, 0)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def looks_like_lane(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
return "flags" in value and "maxSpeed" in value and (
|
||||
"length" in value or "playerGPSInfo" in value or "laneNumber" in value
|
||||
)
|
||||
|
||||
|
||||
def has_flag(flags: int, flag: str) -> bool:
|
||||
return (flags & FLAGS[flag]) == FLAGS[flag]
|
||||
|
||||
|
||||
def patch_lane(
|
||||
lane: dict[str, Any],
|
||||
*,
|
||||
highway_speed: int,
|
||||
road_speed_floor: int,
|
||||
non_highway_cap: int | None,
|
||||
) -> tuple[bool, str | None]:
|
||||
flags = as_int(lane.get("flags"))
|
||||
speed = as_int(lane.get("maxSpeed"))
|
||||
if flags is None or speed is None:
|
||||
return False, None
|
||||
|
||||
if has_flag(flags, "TrafficDisabled") or has_flag(flags, "Blockade"):
|
||||
return False, None
|
||||
if has_flag(flags, "Pavement") or has_flag(flags, "CrossWalk"):
|
||||
return False, None
|
||||
|
||||
is_highway = has_flag(flags, "Highway")
|
||||
is_road = has_flag(flags, "Road") or has_flag(flags, "GPSOnly")
|
||||
|
||||
target = speed
|
||||
reason: str | None = None
|
||||
|
||||
if is_highway and speed < highway_speed:
|
||||
target = highway_speed
|
||||
reason = "highway"
|
||||
elif is_road and speed < road_speed_floor:
|
||||
target = road_speed_floor
|
||||
reason = "road"
|
||||
|
||||
if non_highway_cap is not None and not is_highway and target > non_highway_cap:
|
||||
target = non_highway_cap
|
||||
reason = "cap"
|
||||
|
||||
if target == speed:
|
||||
return False, None
|
||||
|
||||
set_wrapped(lane, "maxSpeed", target)
|
||||
return True, reason
|
||||
|
||||
|
||||
def walk_and_patch(value: Any, stats: PatchStats, args: argparse.Namespace) -> None:
|
||||
if isinstance(value, dict):
|
||||
lanes = value.get("lanes")
|
||||
if isinstance(lanes, list) and any(looks_like_lane(lane) for lane in lanes):
|
||||
stats.lane_sets_seen += 1
|
||||
for lane in lanes:
|
||||
if not looks_like_lane(lane):
|
||||
continue
|
||||
stats.lanes_seen += 1
|
||||
changed, reason = patch_lane(
|
||||
lane,
|
||||
highway_speed=args.highway_speed,
|
||||
road_speed_floor=args.road_speed_floor,
|
||||
non_highway_cap=args.non_highway_cap,
|
||||
)
|
||||
if changed:
|
||||
stats.lanes_changed += 1
|
||||
if reason == "highway":
|
||||
stats.highway_lanes_changed += 1
|
||||
elif reason == "road":
|
||||
stats.road_lanes_changed += 1
|
||||
elif reason == "cap":
|
||||
stats.capped_lanes_changed += 1
|
||||
|
||||
for child in value.values():
|
||||
walk_and_patch(child, stats, args)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
walk_and_patch(child, stats, args)
|
||||
|
||||
|
||||
def iter_json_files(path: Path) -> list[Path]:
|
||||
if path.is_file():
|
||||
return [path]
|
||||
return sorted(path.rglob("*.json"))
|
||||
|
||||
|
||||
def output_path(input_file: Path, input_root: Path, output_root: Path) -> Path:
|
||||
if input_root.is_file():
|
||||
return output_root
|
||||
return output_root / input_file.relative_to(input_root)
|
||||
|
||||
|
||||
def patch_file(input_file: Path, input_root: Path, output_root: Path, args: argparse.Namespace, stats: PatchStats) -> None:
|
||||
stats.files_seen += 1
|
||||
original = input_file.read_text(encoding="utf-8")
|
||||
data = json.loads(original)
|
||||
patched = copy.deepcopy(data)
|
||||
|
||||
before_changed = stats.lanes_changed
|
||||
walk_and_patch(patched, stats, args)
|
||||
changed = stats.lanes_changed != before_changed
|
||||
if changed:
|
||||
stats.files_changed += 1
|
||||
|
||||
destination = output_path(input_file, input_root, output_root)
|
||||
if args.dry_run:
|
||||
return
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if changed or args.copy_unchanged:
|
||||
destination.write_text(
|
||||
json.dumps(patched, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
elif input_root.is_file():
|
||||
destination.write_text(original, encoding="utf-8")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input", type=Path, help="WolvenKit JSON file or export directory")
|
||||
parser.add_argument("output", type=Path, help="Patched JSON file or output directory")
|
||||
parser.add_argument("--highway-speed", type=int, default=90)
|
||||
parser.add_argument("--road-speed-floor", type=int, default=45)
|
||||
parser.add_argument("--non-highway-cap", type=int, default=None)
|
||||
parser.add_argument("--copy-unchanged", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not args.input.exists():
|
||||
raise SystemExit(f"Input does not exist: {args.input}")
|
||||
|
||||
stats = PatchStats()
|
||||
for input_file in iter_json_files(args.input):
|
||||
patch_file(input_file, args.input, args.output, args, stats)
|
||||
|
||||
print(f"files seen: {stats.files_seen}")
|
||||
print(f"files changed: {stats.files_changed}")
|
||||
print(f"lane sets seen: {stats.lane_sets_seen}")
|
||||
print(f"lanes seen: {stats.lanes_seen}")
|
||||
print(f"lanes changed: {stats.lanes_changed}")
|
||||
print(f"highway boosts: {stats.highway_lanes_changed}")
|
||||
print(f"road boosts: {stats.road_lanes_changed}")
|
||||
print(f"non-highway caps: {stats.capped_lanes_changed}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user