Document full GPS solver lead
This commit is contained in:
+72
-12
@@ -6,8 +6,8 @@ Current date/time context: 2026-06-26, local timezone America/Chicago.
|
|||||||
|
|
||||||
- Installed RED4ext state is safe: the game plugin directory contains
|
- Installed RED4ext state is safe: the game plugin directory contains
|
||||||
`EdgeWeightGPS.dll.disabled`, not an active `EdgeWeightGPS.dll`.
|
`EdgeWeightGPS.dll.disabled`, not an active `EdgeWeightGPS.dll`.
|
||||||
- Latest committed safe source before this update: `55f4087 Document VAND GPS
|
- Latest committed source before this handoff update:
|
||||||
search graph`.
|
`7c2ef33 Hook full GPS solver node weights`.
|
||||||
- Current source has `kEnableGpsTrafficEdgeWeightPatch = false` and
|
- Current source has `kEnableGpsTrafficEdgeWeightPatch = false` and
|
||||||
`kEnableGpsLocalSearchHooks = false`.
|
`kEnableGpsLocalSearchHooks = false`.
|
||||||
- The real route search is confirmed at `0x44f054`, called from
|
- The real route search is confirmed at `0x44f054`, called from
|
||||||
@@ -105,18 +105,74 @@ Important caveat:
|
|||||||
|
|
||||||
## Current Installed State
|
## Current Installed State
|
||||||
|
|
||||||
- Installed RED4ext plugin is disabled:
|
- Active RED4ext test plugin is installed:
|
||||||
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll.disabled`.
|
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll`.
|
||||||
- The latest tested DLL is also preserved disabled as:
|
- Older DLLs are preserved disabled as:
|
||||||
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dd1423b.dll.disabled`.
|
`Cyberpunk 2077/red4ext/plugins/EdgeWeightGPS/EdgeWeightGPS.dll.disabled`
|
||||||
- There is no active `EdgeWeightGPS.dll` in the plugin directory.
|
and `EdgeWeightGPS.dd1423b.dll.disabled`.
|
||||||
- Source defaults are currently read-only for route behavior again:
|
- Source defaults now enable the full async solver multiplier hook:
|
||||||
`kEnableGpsSpatialEdgeWeightPatch = false`. Focused final-route result tracing
|
`kEnableGpsMultiplierHooks = true`.
|
||||||
remains available behind `kEnableGpsQueryResultTraceHooks = 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.
|
||||||
|
- The plugin log was truncated immediately before installing this build.
|
||||||
- Latest archived test log:
|
- Latest archived test log:
|
||||||
`logs/EdgeWeightGPS_spatial_inversion_no_route_change_1845.log`.
|
`logs/EdgeWeightGPS_spatial_inversion_no_route_change_1845.log`.
|
||||||
- Game is cold as of the latest user message.
|
- Game is cold as of the latest user message.
|
||||||
|
|
||||||
|
## 2026-06-26 Full Async Solver Findings
|
||||||
|
|
||||||
|
- Static route pipeline:
|
||||||
|
`0x8d20d4` map route submit -> `0x70a42c` query manager ->
|
||||||
|
`0x70a570` dispatch -> `0x8d17d8`/`0x8d1880` async enqueue.
|
||||||
|
- Worker loop around `0x126c06` consumes queued route requests from
|
||||||
|
`routeSystem+0x47c0`.
|
||||||
|
- Worker calls either `0x818928` directly or calls `0x883cd8` first, then
|
||||||
|
`0x818928`. Successful results pass through `0x11f5f60`.
|
||||||
|
- `0x883cd8` resolves/projects start and end candidates with helpers including
|
||||||
|
`0x198fb8`, `0x886958`, and `0x197b04`, then calls `0x1caa7a0`.
|
||||||
|
- `0x1caa7a0` initializes the full async traffic solver: it sets
|
||||||
|
`solver+0x10 = 1`, stores route mode at `solver+0xc4`, sets route masks at
|
||||||
|
`solver+0xc0/+0xc2`, copies candidate arrays into `solver+0x70/+0x80`, and
|
||||||
|
calls `0x88367c`.
|
||||||
|
- `0x88367c` marks start candidate lane/node records with `node+0x9a |= 1`,
|
||||||
|
marks end candidates with `node+0x9a |= 2`, computes initial costs at
|
||||||
|
node-state `+0x10/+0x14`, and queues nodes into open list `solver+0xd0`.
|
||||||
|
- `0x818928` calls `0x8189f0`; when complete, `0x818ba8` builds the output
|
||||||
|
route records.
|
||||||
|
- `0x8189f0` advances with `0x40ade4`, then materializes output with
|
||||||
|
`0x818ba8`.
|
||||||
|
- `0x40ade4` is the true full-route solver advance loop. It pops items from
|
||||||
|
`solver+0xd0` while `solver+0xdc != 0`, calls expansion helpers including
|
||||||
|
`0x40b304` and `0x40b540`, then marks state byte `+0x22 = 2`.
|
||||||
|
- `0x40b304` and `0x40b540` expand neighbor lists from lane/node records at
|
||||||
|
`solver+0xf8 + index*0xa0`.
|
||||||
|
- Neighbor admissibility is `0x40b774`, which checks flags at `node+0x88`:
|
||||||
|
pass iff `(solver+0xc0 & flags88) != 0` and
|
||||||
|
`(solver+0xc2 & flags88) == 0`.
|
||||||
|
- Main cost formula in `0x40b304`:
|
||||||
|
`g = current.g + delta * 0x40bb40(solver,node) * directionMultiplier`;
|
||||||
|
heuristic is straight-line distance to the solver target position; `f = g+h`.
|
||||||
|
- `0x40ba58` is the relax/update helper. It writes candidate `f` to node-state
|
||||||
|
`+0x14`, `g` to `+0x10`, progress to `+0x18`, predecessor state to `+0x1e`,
|
||||||
|
marks state byte `+0x22 = 1`, and pushes to open list `solver+0xd0`.
|
||||||
|
- Vanilla `0x40bb40` is the key full-solver per-node multiplier. For driving
|
||||||
|
modes it treats `Road` (`0x0010`) as `1.0` or `1.2`, with an extra `1.1`
|
||||||
|
multiplier if byte `node+0x93 == 1`. It does not special-case `Highway`
|
||||||
|
(`0x4000`).
|
||||||
|
- Vanilla non-road fallback uses the float table at `0x3154d28`:
|
||||||
|
`10, 6, 2, 0, 4, 2.5, 1.5, 0`.
|
||||||
|
- Vanilla `0x40bb00` auxiliary multiplier returns `1.1` for most road cases,
|
||||||
|
or uses the table at `0x3154d38` (`4, 2.5, 1.5, 0`) for one mode-2/retry
|
||||||
|
branch.
|
||||||
|
- Practical inference: the full solver already has a real edge-cost surface,
|
||||||
|
but vanilla highways are merely roads. The installed build patches that exact
|
||||||
|
multiplier instead of the earlier local VAND search callback.
|
||||||
|
|
||||||
## New Toolbox Tools
|
## New Toolbox Tools
|
||||||
|
|
||||||
The Fedora toolbox `2077` now has:
|
The Fedora toolbox `2077` now has:
|
||||||
@@ -132,6 +188,7 @@ instead of one-off pip installs.
|
|||||||
|
|
||||||
In `red4ext/EdgeWeightGPS/src/Main.cpp`:
|
In `red4ext/EdgeWeightGPS/src/Main.cpp`:
|
||||||
|
|
||||||
|
- `kEnableGpsMultiplierHooks = true`
|
||||||
- `kEnableGpsProviderClassPatch = false`
|
- `kEnableGpsProviderClassPatch = false`
|
||||||
- `kEnableGpsProviderClassPatchTrace = false`
|
- `kEnableGpsProviderClassPatchTrace = false`
|
||||||
- `kEnableGpsTraceHooks = true`
|
- `kEnableGpsTraceHooks = true`
|
||||||
@@ -143,8 +200,9 @@ In `red4ext/EdgeWeightGPS/src/Main.cpp`:
|
|||||||
- `kEnableGpsLocalSearchHooks = false`
|
- `kEnableGpsLocalSearchHooks = false`
|
||||||
- `kEnableRouteUiTraceHooks = false`
|
- `kEnableRouteUiTraceHooks = false`
|
||||||
|
|
||||||
If rebuilt with the current defaults, active hooks are focused map-route submit
|
If rebuilt with the current defaults, active hooks are focused map-route submit,
|
||||||
and final route-result fetch tracing only. The spatial edge-cost patch requires
|
final route-result fetch tracing, and the full solver multiplier hook at
|
||||||
|
`0x40bb00`/`0x40bb40`. The spatial edge-cost patch requires
|
||||||
`kEnableGpsSpatialEdgeWeightPatch = true`. The full local search/cost hooks below require
|
`kEnableGpsSpatialEdgeWeightPatch = true`. The full local search/cost hooks below require
|
||||||
`kEnableGpsLocalSearchHooks = true`:
|
`kEnableGpsLocalSearchHooks = true`:
|
||||||
|
|
||||||
@@ -159,6 +217,8 @@ and final route-result fetch tracing only. The spatial edge-cost patch requires
|
|||||||
- `GPSEdgeCost 0x44f838`
|
- `GPSEdgeCost 0x44f838`
|
||||||
- filtered provider filter `0x44ff68`
|
- filtered provider filter `0x44ff68`
|
||||||
- base provider filter `0x450b08`
|
- base provider filter `0x450b08`
|
||||||
|
- full solver auxiliary multiplier `0x40bb00`
|
||||||
|
- full solver node multiplier `0x40bb40`
|
||||||
|
|
||||||
Async route-job hooks (`0x8d17d8`, `0x126b28`, `0x883cd8`,
|
Async route-job hooks (`0x8d17d8`, `0x126b28`, `0x883cd8`,
|
||||||
`0x11f5f60`, `0x818ba8`) are split behind
|
`0x11f5f60`, `0x818ba8`) are split behind
|
||||||
|
|||||||
@@ -873,15 +873,139 @@ identical record counts. Therefore `0x44f838` remains a real local graph edge
|
|||||||
cost callback, but changing it does not change the final selected route records
|
cost callback, but changing it does not change the final selected route records
|
||||||
for the tested full player GPS routes.
|
for the tested full player GPS routes.
|
||||||
|
|
||||||
|
Static disassembly after that failure found a separate, broader async traffic
|
||||||
|
solver upstream of final route-record materialization. The useful path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
map route submit 0x8d20d4
|
||||||
|
shared query submit 0x70a42c
|
||||||
|
query dispatch 0x70a570
|
||||||
|
async request enqueue 0x8d17d8 / 0x8d1880
|
||||||
|
worker queue loop 0x126c06
|
||||||
|
candidate/projection setup 0x883cd8
|
||||||
|
solver start wrapper 0x1caa7a0
|
||||||
|
solver candidate initialization 0x88367c
|
||||||
|
route job start/advance 0x818928
|
||||||
|
solver advance loop 0x40ade4
|
||||||
|
neighbor expansion 0x40b304 / 0x40b540
|
||||||
|
relax/update helper 0x40ba58
|
||||||
|
output route build 0x818ba8
|
||||||
|
```
|
||||||
|
|
||||||
|
This path is distinct from the local `0x44f054` VAND search. The worker around
|
||||||
|
`0x126c06` pulls queued route requests from `routeSystem+0x47c0`. Depending on
|
||||||
|
state it either calls `0x818928` directly or first calls `0x883cd8`, which
|
||||||
|
resolves/projects the start and end positions into candidate arrays. The
|
||||||
|
wrapper at `0x1caa7a0` starts the solver by storing the route mode at
|
||||||
|
`solver+0xc4`, writing route masks at `solver+0xc0/+0xc2`, copying candidate
|
||||||
|
arrays into `solver+0x70/+0x80`, and calling `0x88367c`.
|
||||||
|
|
||||||
|
`0x88367c` marks start candidate lane/node records with `node+0x9a |= 1`, marks
|
||||||
|
end candidates with `node+0x9a |= 2`, computes initial node-state costs at
|
||||||
|
offsets `+0x10/+0x14`, and queues initial states into the open list at
|
||||||
|
`solver+0xd0`.
|
||||||
|
|
||||||
|
`0x40ade4` is the full-route solver advance loop. It processes the open list
|
||||||
|
while `solver+0xdc != 0`, calls helpers including `0x40b304` and `0x40b540` to
|
||||||
|
expand neighbors, and marks completed node states with byte `+0x22 = 2`.
|
||||||
|
Current lane/node records are addressed as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
laneNode = solver + 0xf8 + currentNodeIndex * 0xa0
|
||||||
|
```
|
||||||
|
|
||||||
|
`0x40b304` walks one neighbor list from the current record; `0x40b540` walks a
|
||||||
|
second list with a slightly different edge-record shape. Both filter neighbors
|
||||||
|
through `0x40b774`, which reads the 16-bit lane/node flags at `node+0x88`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pass iff (solver+0xc0 & flags88) != 0
|
||||||
|
and (solver+0xc2 & flags88) == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The main cost equation visible in `0x40b304` is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
g = current.g + progress_delta
|
||||||
|
* 0x40bb40(solver, currentLaneNode)
|
||||||
|
* directionMultiplier
|
||||||
|
h = distance(neighborPosition, solverTargetPosition)
|
||||||
|
f = g + h
|
||||||
|
```
|
||||||
|
|
||||||
|
`directionMultiplier` is normally `1.0`; for reverse/special progress branches
|
||||||
|
it can come from `0x40bb00(solver, currentLaneNode)` or become `FLT_MAX`.
|
||||||
|
`0x40ba58` is the relax/update function. When a candidate improves, it writes
|
||||||
|
`f` to node-state `+0x14`, `g` to `+0x10`, progress to `+0x18`, predecessor to
|
||||||
|
`+0x1e`, marks state byte `+0x22 = 1`, and pushes the state back into
|
||||||
|
`solver+0xd0`.
|
||||||
|
|
||||||
|
The decisive vanilla multiplier is `0x40bb40`. For driving-style modes it reads
|
||||||
|
the same `node+0x88` flags and handles them roughly as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
if mode == 1:
|
||||||
|
pavement flag 0x0008 -> 1.0
|
||||||
|
otherwise -> 1.5
|
||||||
|
|
||||||
|
if mode in {0, 2, 4}:
|
||||||
|
if Road flag 0x0010:
|
||||||
|
base = 1.0 or 1.2 depending on a low node flag
|
||||||
|
if node+0x93 == 1: base *= 1.1
|
||||||
|
return base
|
||||||
|
else:
|
||||||
|
return table[solver+0x54]
|
||||||
|
|
||||||
|
fallback:
|
||||||
|
return 1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The non-road table at `0x3154d28` starts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
10.0, 6.0, 2.0, 0.0, 4.0, 2.5, 1.5, 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The auxiliary multiplier at `0x40bb00` usually returns `1.1` for road cases,
|
||||||
|
or, for one mode-2 branch, reads a smaller table at `0x3154d38`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
4.0, 2.5, 1.5, 0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The important design finding is that vanilla has a real edge-cost surface in
|
||||||
|
the final async solver, but it does not special-case the `Highway` flag
|
||||||
|
(`0x4000`). A highway lane that is also a road is simply a road. That matches
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
## Future Improvements
|
## Future Improvements
|
||||||
|
|
||||||
Better versions of this mod could:
|
Better versions of this mod could:
|
||||||
|
|
||||||
- identify the broader async traffic route search/expansion loop around the
|
- tune the `0x40bb40` solver multiplier after live A/B testing confirms visible
|
||||||
`0x8d25xx` to `0x8d49xx` cluster
|
route changes
|
||||||
- disassemble the full map-route submit/result chain around `0x8d20d4`,
|
- inspect and, if needed, patch `0x40bb00` so reverse/special progress branches
|
||||||
`0x70a42c`, `0x70a570`, `0x7094b8`, and the `0x520783` result drain to find
|
receive the same road-class policy as forward expansion
|
||||||
the route selection or copy step before final records are emitted
|
- add a tiny live-tuning file for the solver multipliers, similar to the earlier
|
||||||
|
`spatial_weights.bin`, once the hook is confirmed stable
|
||||||
- replace the crashing `0x8d46cc` detour with a narrower inline patch or a
|
- replace the crashing `0x8d46cc` detour with a narrower inline patch or a
|
||||||
safer caller-side hook
|
safer caller-side hook
|
||||||
- decode enough of the `VAND` graph to join navigation points to traffic lane
|
- decode enough of the `VAND` graph to join navigation points to traffic lane
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Disassemble a PE .text range by RVA and annotate direct branch targets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
|
||||||
|
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_REG_RIP
|
||||||
|
|
||||||
|
from find_pe_string_xrefs import Section, parse_pe
|
||||||
|
|
||||||
|
|
||||||
|
def find_section(sections: list[Section], rva: int) -> Section:
|
||||||
|
for section in sections:
|
||||||
|
if section.contains_rva(rva):
|
||||||
|
return section
|
||||||
|
raise ValueError(f"RVA 0x{rva:x} is not inside any PE section")
|
||||||
|
|
||||||
|
|
||||||
|
def find_section_or_none(sections: list[Section], rva: int) -> Section | None:
|
||||||
|
for section in sections:
|
||||||
|
if section.contains_rva(rva):
|
||||||
|
return section
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rva(text: str) -> int:
|
||||||
|
return int(text, 16 if text.lower().startswith("0x") else 16)
|
||||||
|
|
||||||
|
|
||||||
|
def format_memory_annotation(data: bytes, sections: list[Section], target_rva: int) -> str:
|
||||||
|
section = find_section_or_none(sections, target_rva)
|
||||||
|
if section is None:
|
||||||
|
return f"rip_rva=0x{target_rva:x}"
|
||||||
|
|
||||||
|
offset = section.rva_to_offset(target_rva)
|
||||||
|
if offset < 0 or offset >= len(data):
|
||||||
|
return f"rip_rva=0x{target_rva:x}"
|
||||||
|
|
||||||
|
available = data[offset : min(len(data), offset + 8)]
|
||||||
|
fields = [f"rip_rva=0x{target_rva:x}"]
|
||||||
|
if len(available) >= 4:
|
||||||
|
u32 = struct.unpack_from("<I", available)[0]
|
||||||
|
f32 = struct.unpack_from("<f", available)[0]
|
||||||
|
fields.append(f"u32=0x{u32:08x}")
|
||||||
|
if math.isfinite(f32) and abs(f32) < 1.0e12:
|
||||||
|
fields.append(f"f32={f32:.9g}")
|
||||||
|
if len(available) >= 8:
|
||||||
|
u64 = struct.unpack_from("<Q", available)[0]
|
||||||
|
fields.append(f"u64=0x{u64:016x}")
|
||||||
|
return " ".join(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("pe", type=Path)
|
||||||
|
parser.add_argument("rva", type=parse_rva)
|
||||||
|
parser.add_argument("--size", type=lambda value: int(value, 0), default=0x200)
|
||||||
|
parser.add_argument("--before", type=lambda value: int(value, 0), default=0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
data = args.pe.read_bytes()
|
||||||
|
image_base, sections = parse_pe(data)
|
||||||
|
start_rva = max(0, args.rva - args.before)
|
||||||
|
section = find_section(sections, start_rva)
|
||||||
|
end_rva = min(section.virtual_address + section.raw_size, start_rva + args.size)
|
||||||
|
start_offset = section.rva_to_offset(start_rva)
|
||||||
|
code = data[start_offset : start_offset + (end_rva - start_rva)]
|
||||||
|
|
||||||
|
disassembler = Cs(CS_ARCH_X86, CS_MODE_64)
|
||||||
|
disassembler.detail = True
|
||||||
|
|
||||||
|
print(f"image_base=0x{image_base:x} section={section.name} range=0x{start_rva:x}..0x{end_rva:x}")
|
||||||
|
for insn in disassembler.disasm(code, image_base + start_rva):
|
||||||
|
rva = insn.address - image_base
|
||||||
|
annotation = ""
|
||||||
|
if insn.group(1) or insn.group(2): # jump/call
|
||||||
|
for operand in insn.operands:
|
||||||
|
if operand.type == X86_OP_IMM:
|
||||||
|
annotation = f" ; target_rva=0x{operand.imm - image_base:x}"
|
||||||
|
break
|
||||||
|
elif insn.operands:
|
||||||
|
memory_annotations = []
|
||||||
|
for operand in insn.operands:
|
||||||
|
if operand.type == X86_OP_MEM and operand.mem.base == X86_REG_RIP:
|
||||||
|
target_rva = (insn.address + insn.size + operand.mem.disp) - image_base
|
||||||
|
memory_annotations.append(format_memory_annotation(data, sections, target_rva))
|
||||||
|
if memory_annotations:
|
||||||
|
annotation = " ; " + " ; ".join(memory_annotations)
|
||||||
|
marker = ">>" if rva == args.rva else " "
|
||||||
|
print(f"{marker} 0x{rva:08x}: {insn.mnemonic:<8} {insn.op_str}{annotation}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user