diff --git a/README.md b/README.md new file mode 100644 index 0000000..a326f53 --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/tooling.md b/docs/tooling.md new file mode 100644 index 0000000..7217b9b --- /dev/null +++ b/docs/tooling.md @@ -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. diff --git a/samples/traffic_resource.json b/samples/traffic_resource.json new file mode 100644 index 0000000..2d159d1 --- /dev/null +++ b/samples/traffic_resource.json @@ -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 + } + } + ] + } + } + } +} diff --git a/tests/test_patch_traffic_lanes.py b/tests/test_patch_traffic_lanes.py new file mode 100644 index 0000000..9bf500b --- /dev/null +++ b/tests/test_patch_traffic_lanes.py @@ -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 diff --git a/tools/build_lane_weight_archive.sh b/tools/build_lane_weight_archive.sh new file mode 100755 index 0000000..fab7363 --- /dev/null +++ b/tools/build_lane_weight_archive.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "usage: $0 " >&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}" diff --git a/tools/cp77_toolbox.sh b/tools/cp77_toolbox.sh new file mode 100755 index 0000000..59691c3 --- /dev/null +++ b/tools/cp77_toolbox.sh @@ -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} $*" diff --git a/tools/discover_traffic_resources.sh b/tools/discover_traffic_resources.sh new file mode 100755 index 0000000..a1c0f08 --- /dev/null +++ b/tools/discover_traffic_resources.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [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}" diff --git a/tools/patch_traffic_lanes.py b/tools/patch_traffic_lanes.py new file mode 100755 index 0000000..fb0ce72 --- /dev/null +++ b/tools/patch_traffic_lanes.py @@ -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())