Summarize joined GPS route results

This commit is contained in:
2026-06-20 21:33:08 -05:00
parent 54f014b74d
commit bf72dcf6e3
3 changed files with 320 additions and 47 deletions
+72 -27
View File
@@ -4,12 +4,12 @@ Current date/time context: 2026-06-20/21, local timezone America/Chicago.
## Current Installed State ## Current Installed State
- Latest commit: `64ddf7a Trace GPS provider filters`. - Latest installed probe commit: `54f014b Trace full GPS query results`.
- Installed RED4ext plugin: - Installed RED4ext plugin:
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll`. `Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll`.
- Installed build is read-only. It should not alter route behavior. - Installed build is read-only. It should not alter route behavior.
- Plugin log is cleared and empty before the next test: - Latest archived test log:
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.log`. `logs/EdgeWeightGPS_query_route_probe_2308.log`.
- Game is cold as of the latest user message. - Game is cold as of the latest user message.
## New Toolbox Tools ## New Toolbox Tools
@@ -32,6 +32,7 @@ In `red4ext/EdgeWeightGPS/src/Main.cpp`:
- `kEnableGpsTraceHooks = true` - `kEnableGpsTraceHooks = true`
- `kEnableGpsQueryLifecycleHooks = true` - `kEnableGpsQueryLifecycleHooks = true`
- `kEnableGpsRouteJobLifecycleHooks = false` - `kEnableGpsRouteJobLifecycleHooks = false`
- `kEnableGpsRouteJobStartTrace = false`
- `kEnableGpsLocalSearchHooks = true` - `kEnableGpsLocalSearchHooks = true`
- `kEnableRouteUiTraceHooks = false` - `kEnableRouteUiTraceHooks = false`
@@ -113,37 +114,81 @@ Route result records:
- `h00` in those records matches `worldTrafficLanePersistent.nodeRefHash`. - `h00` in those records matches `worldTrafficLanePersistent.nodeRefHash`.
- Route records can be joined to `work/raw-segment-json/all.traffic_persistent.json`. - Route records can be joined to `work/raw-segment-json/all.traffic_persistent.json`.
- The low byte of route-record `u08` is not the same as the search point class. - The low byte of route-record `u08` is not the same as the search point class.
- The log's human wall timestamp has a millisecond rollover bug around second
boundaries. Use the `+Nms` elapsed field for ordering and durations.
## Next User Test ## Latest 23:08 Test Result
Ask the user to run the usual routine: The user clicked:
1. Launch game. - 23:08 continue on main menu
2. Hit Continue on main menu. - 23:30 space to continue
3. Hit Space to Continue. - 23:46 map open
4. Wait for minimap/GPS to settle. - 23:56 side job
5. Open map. - 24:06 Sinnerman
6. Plot the usual three routes plus one custom highway-ish target. - 24:14 Claire
7. Close game. - 24:21 custom
- 24:30 Aldecaldo camp
- 24:47 far gig east
- 25:03 Violence
- 25:10 cyberpsycho unfinished highway
Ask for timestamps for: Use:
- Space-to-Continue ```bash
- map open python3 tools/summarize_gps_route_records.py \
- each route click --traffic-json work/raw-segment-json/all.traffic_persistent.json \
- game cold logs/EdgeWeightGPS_query_route_probe_2308.log
```
After the run: Important result table:
1. Archive the log to `logs/EdgeWeightGPS_provider_filter_probe_<time>.log`. - qid 1: startup/minimap route, 18 records, 50 ms, 1216.5 pavement length.
2. Inspect `GPSQuerySubmit`/`GPSQueryResultFetch` lines for tracked route IDs - qid 2: side job, 12 records, 32 ms, 578.6 pavement length.
from submit return RVA `0x8d20d4`, especially successful `0x7094b8` - qid 3: Sinnerman, 23 records, 45 ms, 897.4 pavement, 160.4 highway,
records with `gpsResult_ptr28_rec40`. 89.8 road.
3. Inspect `GPSSearch result` lines for `filterPassClasses`, - qid 4: Claire, 18 records, 34 ms, 1216.5 pavement.
`filterFailClasses`, `filterPassMaskBits`, and `filterFailMaskBits`. - qid 5: custom, 17 records, 44 ms, 1006.3 pavement, 241.0 highway,
4. Compare filter pass/fail classes to `edgeSrcClasses` and route output 38.0 road.
records. The key question is whether the class/mask filter is selecting the - qid 6: Aldecaldo camp, 80 records, 325 ms, 5853.5 road, 1492.9 highway,
graph subset before cost weighting can matter. 1111.5 pavement, 24.6 gpsonly.
- qid 7: far east gig, 81 records, 144 ms, 5086.2 road, 1523.0 highway,
1111.5 pavement, 24.6 gpsonly.
- qid 8: Violence, 55 records, 97 ms, 1991.5 pavement, 358.5 highway,
86.1 road.
- qid 9: cyberpsycho unfinished highway, 58 records, 147 ms, 2657.4 pavement,
519.0 highway, 113.2 road, 56.7 gpsonly.
All 362 route-record handles matched traffic lane hashes in the extracted
resource.
Local search/provider aggregate from this run:
- 159 searches, 4036 provider filter calls, only 3 filter failures.
- Filter pass classes: `3:3114`, `5:451`, `15:310`, `1:116`, `4:42`.
- Edge source classes: `3:3244`, `5:487`, `15:318`, `1:117`, `4:58`.
- Edge return RVAs: `0x44f457:4033`, `0x44f629:151`,
`0x8d74d3:30`, `0x8d76b0:10`.
Inference:
- Provider vtable slot `+0x08` filtering is almost not pruning these route
searches, so it is not the dominant reason class-cost patches failed to
visibly steer GPS.
- Search-side edge classes are only `{1,3,4,5,15}` in this run. Final emitted
route-record classes include `{0,1,2,3,4,5,7,8,9}` and class 7/8/9 are
highway-heavy. Therefore final route-record `u08` low byte and search point
`point+0x13` class are different domains.
## Next Work
- Static disassembly around result-drain/producer RVAs `0x520783`,
`0x41be2c`, `0x41be94`, `0x70a42c`, and `0x8d20d4`.
- The plausible patch point is upstream of result copy: find where search
output is converted to 0x28 route records or where candidate lanes are ranked
with enough lane-hash/resource context to prefer highway/major-road lanes.
- Post-`0x7094b8` rewriting would likely only change displayed path records and
risks desynchronizing instructions from the real planner.
## Useful Commands ## Useful Commands
+31
View File
@@ -264,6 +264,37 @@ Route-record/resource correlation:
provider initialization site or a way to join search-side route points back to provider initialization site or a way to join search-side route points back to
`nodeRefHash`/lane flags before the edge cost is returned. `nodeRefHash`/lane flags before the edge cost is returned.
Latest full-route join:
- `logs/EdgeWeightGPS_query_route_probe_2308.log` contains nine solved route
results from the 23:08 user test. All 362 emitted route records matched
`worldTrafficLanePersistent.nodeRefHash` in
`work/raw-segment-json/all.traffic_persistent.json`.
- The log's wall timestamp currently wraps milliseconds without incrementing
the displayed second. Use the `+Nms` elapsed field when comparing submit and
result times.
- Representative solved route composition:
- side job: 12 records, 32 ms, 578.6 pavement length.
- Sinnerman: 23 records, 45 ms, 897.4 pavement, 160.4 highway, 89.8 road.
- Claire: 18 records, 34 ms, 1216.5 pavement.
- custom highway-ish pin: 17 records, 44 ms, 1006.3 pavement,
241.0 highway, 38.0 road.
- Aldecaldo camp: 80 records, 325 ms, 5853.5 road, 1492.9 highway,
1111.5 pavement, 24.6 gpsonly.
- far east gig: 81 records, 144 ms, 5086.2 road, 1523.0 highway,
1111.5 pavement, 24.6 gpsonly.
- Violence: 55 records, 97 ms, 1991.5 pavement, 358.5 highway,
86.1 road.
- cyberpsycho unfinished highway: 58 records, 147 ms, 2657.4 pavement,
519.0 highway, 113.2 road, 56.7 gpsonly.
- Search/provider aggregate in the same log: 159 searches, 4036 provider
filter calls, only 3 filter failures. Filter slot `+0x08` is therefore
almost never pruning this test graph.
- Search-side point classes observed by `0x44f838` are `{1,3,4,5,15}`. Final
route-record low-byte classes are `{0,1,2,3,4,5,7,8,9}`, and route-record
classes 7/8/9 are highway-heavy. These are different domains, which explains
why provider point-class tuning did not map cleanly to highway preference.
Current static producer lead: Current static producer lead:
- `0x70a908` packages the route endpoints and query settings, then calls - `0x70a908` packages the route endpoints and query settings, then calls
+217 -20
View File
@@ -5,15 +5,21 @@ from __future__ import annotations
import argparse import argparse
import collections import collections
import dataclasses
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
RE_TIMESTAMP = re.compile(r"^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})")
RE_ELAPSED_MS = re.compile(r" \+(\d+)ms ")
RE_RECORD_BLOCK = re.compile(r"gpsResult_ptr28_rec40=\[(.*?)\]") RE_RECORD_BLOCK = re.compile(r"gpsResult_ptr28_rec40=\[(.*?)\]")
RE_RECORD = re.compile(r"(\d+):([0-9a-f]+),0x([0-9a-f]{8}),([^;\]]+)") RE_RECORD = re.compile(r"(\d+):([0-9a-f]+),0x([0-9a-f]{8}),([^;\]]+)")
RE_JOB_COUNT = re.compile(r"GPSRouteJobBuild result call=(\d+).*?job_count50=(\d+)/0x([0-9a-f]+)") RE_JOB_COUNT = re.compile(r"GPSRouteJobBuild result call=(\d+).*?job_count50=(\d+)/0x([0-9a-f]+)")
RE_FIELD_DEC_HEX = re.compile(r"([A-Za-z0-9_]+)=(-?\d+)/0x([0-9a-fA-F]+)")
RE_FIELD_HEX = re.compile(r"([A-Za-z0-9_]+)=0x([0-9a-fA-F]+)")
RE_FIELD_DEC = re.compile(r"([A-Za-z0-9_]+)=(-?\d+)(?= |$)")
FLAGS = { FLAGS = {
"FromRoadSpline": 1, "FromRoadSpline": 1,
@@ -35,6 +41,39 @@ FLAGS = {
} }
@dataclasses.dataclass
class SubmitInfo:
line_no: int
timestamp: str
elapsed_ms: int | None
call: int
fields: dict[str, int]
@dataclasses.dataclass
class RouteRecord:
index: int
handle: int
packed: int
parts: list[str]
@property
def low_class(self) -> int:
return self.packed & 0xFF
@dataclasses.dataclass
class RouteResult:
line_no: int
timestamp: str
elapsed_ms: int | None
query_id: int | None
submit_call: int | None
submit_ret_rva: int | None
fields: dict[str, int]
records: list[RouteRecord]
def bytes_le(value: int) -> tuple[int, int, int, int]: def bytes_le(value: int) -> tuple[int, int, int, int]:
return tuple((value >> (8 * index)) & 0xFF for index in range(4)) return tuple((value >> (8 * index)) & 0xFF for index in range(4))
@@ -57,6 +96,27 @@ def lane_category(flags: int) -> str:
return "other" return "other"
def parse_timestamp(line: str) -> str:
match = RE_TIMESTAMP.search(line)
return match.group(1) if match else "<unknown>"
def parse_elapsed_ms(line: str) -> int | None:
match = RE_ELAPSED_MS.search(line)
return int(match.group(1)) if match else None
def parse_fields(line: str) -> dict[str, int]:
fields: dict[str, int] = {}
for match in RE_FIELD_DEC_HEX.finditer(line):
fields[match.group(1)] = int(match.group(2))
for match in RE_FIELD_HEX.finditer(line):
fields.setdefault(match.group(1), int(match.group(2), 16))
for match in RE_FIELD_DEC.finditer(line):
fields.setdefault(match.group(1), int(match.group(2)))
return fields
def load_lane_lookup(path: Path | None) -> dict[int, dict[str, Any]]: def load_lane_lookup(path: Path | None) -> dict[int, dict[str, Any]]:
if not path: if not path:
return {} return {}
@@ -79,7 +139,158 @@ def load_lane_lookup(path: Path | None) -> dict[int, dict[str, Any]]:
return lookup return lookup
def parse_route_records(block: str) -> list[RouteRecord]:
records: list[RouteRecord] = []
for record_match in RE_RECORD.finditer(block):
pieces = record_match.group(4).split(",")
if len(pieces) < 4:
continue
records.append(
RouteRecord(
index=int(record_match.group(1)),
handle=int(record_match.group(2), 16),
packed=int(record_match.group(3), 16),
parts=pieces,
)
)
return records
def iter_route_results(path: Path) -> tuple[dict[int, SubmitInfo], list[RouteResult], list[int]]:
submits: dict[int, SubmitInfo] = {}
results: list[RouteResult] = []
job_counts: list[int] = []
for line_no, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1):
fields = parse_fields(line)
if "hook GPSQuerySubmit 0x70a42c" in line:
call = fields.get("call")
if call is not None:
submits[call] = SubmitInfo(
line_no=line_no,
timestamp=parse_timestamp(line),
elapsed_ms=parse_elapsed_ms(line),
call=call,
fields=fields,
)
job_match = RE_JOB_COUNT.search(line)
if job_match:
job_counts.append(int(job_match.group(2)))
block_match = RE_RECORD_BLOCK.search(line)
if not block_match:
continue
records = parse_route_records(block_match.group(1))
if not records:
continue
results.append(
RouteResult(
line_no=line_no,
timestamp=parse_timestamp(line),
elapsed_ms=parse_elapsed_ms(line),
query_id=fields.get("queryId"),
submit_call=fields.get("submitCall"),
submit_ret_rva=fields.get("submitRetRva"),
fields=fields,
records=records,
)
)
return submits, results, job_counts
def summarize_route_result(route: RouteResult, lane_lookup: dict[int, dict[str, Any]]) -> tuple[dict[str, Any], int]:
class_counts = collections.Counter(record.low_class for record in route.records)
category_counts: collections.Counter[str] = collections.Counter()
category_lengths: collections.Counter[str] = collections.Counter()
speed_lengths: collections.Counter[int] = collections.Counter()
unmatched = 0
for record in route.records:
lane = lane_lookup.get(record.handle)
if not lane:
unmatched += 1
continue
category = lane["category"]
length = float(lane.get("length") or 0.0)
category_counts[category] += 1
category_lengths[category] += length
speed_lengths[int(lane["speed"])] += length
total_length = sum(category_lengths.values())
summary = {
"class_counts": class_counts,
"category_counts": category_counts,
"category_lengths": category_lengths,
"speed_lengths": speed_lengths,
"total_length": total_length,
"unmatched": unmatched,
}
return summary, len(route.records) - unmatched
def format_counter(counter: collections.Counter[Any], limit: int | None = None, digits: int | None = None) -> str:
items = counter.most_common(limit)
parts = []
for key, value in items:
if digits is None:
parts.append(f"{key}:{value}")
else:
parts.append(f"{key}:{value:.{digits}f}")
return "{" + ", ".join(parts) + "}"
def print_per_route(
path: Path,
submits: dict[int, SubmitInfo],
route_results: list[RouteResult],
lane_lookup: dict[int, dict[str, Any]],
) -> None:
print(" per-route results:")
for ordinal, route in enumerate(route_results, start=1):
submit = submits.get(route.submit_call) if route.submit_call is not None else None
summary, matched = summarize_route_result(route, lane_lookup)
span = route.fields.get("gpsResult_u20")
submit_fields = submit.fields if submit else {}
ret_rva = route.submit_ret_rva
ret_text = f"0x{ret_rva:x}" if ret_rva is not None else "?"
submit_ms = submit.elapsed_ms if submit else None
result_ms = route.elapsed_ms
duration_ms = result_ms - submit_ms if submit_ms is not None and result_ms is not None else None
submit_marker = f"+{submit_ms}ms" if submit_ms is not None else "?"
result_marker = f"+{result_ms}ms" if result_ms is not None else "?"
duration_marker = f"{duration_ms}ms" if duration_ms is not None else "?"
print(
f" route#{ordinal} qid={route.query_id} submitCall={route.submit_call} "
f"submit={submit_marker} result={result_marker} duration={duration_marker} line={route.line_no} "
f"ret={ret_text} records={len(route.records)} spanU20={span} matched={matched}"
)
if submit_fields:
print(
" query "
f"f08=0x{submit_fields.get('query_f08', 0):x} "
f"f0c={submit_fields.get('query_f0c')} "
f"fcc={submit_fields.get('query_fcc')}"
)
if lane_lookup:
category_lengths = summary["category_lengths"]
total = summary["total_length"]
print(
" resource "
f"segments={format_counter(summary['category_counts'])} "
f"length={format_counter(category_lengths, digits=1)} "
f"total={total:.1f} "
f"speedLength={format_counter(summary['speed_lengths'], limit=6, digits=1)} "
f"unmatched={summary['unmatched']}"
)
print(f" routeClasses={format_counter(summary['class_counts'])}")
def summarize(path: Path, lane_lookup: dict[int, dict[str, Any]]) -> None: def summarize(path: Path, lane_lookup: dict[int, dict[str, Any]]) -> None:
submits, route_results, job_counts = iter_route_results(path)
routes = 0 routes = 0
records = 0 records = 0
first_byte = collections.Counter() first_byte = collections.Counter()
@@ -91,37 +302,22 @@ def summarize(path: Path, lane_lookup: dict[int, dict[str, Any]]) -> None:
class_speed: dict[int, collections.Counter[int]] = collections.defaultdict(collections.Counter) class_speed: dict[int, collections.Counter[int]] = collections.defaultdict(collections.Counter)
matched_handles = 0 matched_handles = 0
unmatched_handles = 0 unmatched_handles = 0
job_counts: list[int] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
job_match = RE_JOB_COUNT.search(line)
if job_match:
job_counts.append(int(job_match.group(2)))
block_match = RE_RECORD_BLOCK.search(line)
if not block_match:
continue
for route in route_results:
route_records = 0 route_records = 0
for record_match in RE_RECORD.finditer(block_match.group(1)): for record in route.records:
handle = record_match.group(2)
packed = int(record_match.group(3), 16)
pieces = record_match.group(4).split(",")
if len(pieces) < 4:
continue
route_records += 1 route_records += 1
records += 1 records += 1
bytes_tuple = bytes_le(packed) bytes_tuple = bytes_le(record.packed)
class_id = bytes_tuple[0] class_id = bytes_tuple[0]
patterns[bytes_tuple] += 1 patterns[bytes_tuple] += 1
first_byte[class_id] += 1 first_byte[class_id] += 1
for index, byte in enumerate(bytes_tuple): for index, byte in enumerate(bytes_tuple):
byte_positions[index][byte] += 1 byte_positions[index][byte] += 1
segments[(handle, class_id)] += 1 segments[(record.handle, class_id)] += 1
if lane_lookup: if lane_lookup:
lane = lane_lookup.get(int(handle, 16)) lane = lane_lookup.get(record.handle)
if lane: if lane:
matched_handles += 1 matched_handles += 1
class_categories[class_id][lane["category"]] += 1 class_categories[class_id][lane["category"]] += 1
@@ -155,6 +351,7 @@ def summarize(path: Path, lane_lookup: dict[int, dict[str, Any]]) -> None:
speeds = dict(class_speed[class_id].most_common(8)) speeds = dict(class_speed[class_id].most_common(8))
flags = dict(class_flags[class_id].most_common(8)) flags = dict(class_flags[class_id].most_common(8))
print(f" {class_id}: categories={categories} speeds={speeds} flags={flags}") print(f" {class_id}: categories={categories} speeds={speeds} flags={flags}")
print_per_route(path, submits, route_results, lane_lookup)
for index, counter in enumerate(byte_positions): for index, counter in enumerate(byte_positions):
print(f" byte{index} counts:", dict(counter.most_common(12))) print(f" byte{index} counts:", dict(counter.most_common(12)))
print(" packed metadata patterns:") print(" packed metadata patterns:")