1025 lines
38 KiB
Markdown
1025 lines
38 KiB
Markdown
# Cyberpunk 2077 Traffic/GPS System Debrief
|
|
|
|
This is based on the public NativeDB RTTI dump, WolvenKit exports from the local
|
|
Steam install, and the generated `all.traffic_persistent` resource from the
|
|
Phantom Liberty archive.
|
|
|
|
## Current Working Theory
|
|
|
|
The first hypothesis was that the in-game car GPS routes over the generated
|
|
traffic lane graph and consumes lane metadata such as `maxSpeed`, highway flags,
|
|
and lane connection probabilities. Live tests have not supported that:
|
|
|
|
- boosting highway `maxSpeed` changed freeway traffic speed but did not change
|
|
player GPS route choice
|
|
- changing `all.lane_connections` exit probabilities did not change player GPS
|
|
route choice
|
|
- probing the obvious `TrafficSystem_Pathfinding` and
|
|
`StartPathfinding`/`StopPathfinding` string-xref wrappers did not fire during
|
|
deliberate world-map route plotting
|
|
- probing the obvious `RunGPSQuery` and `UpdateGPSQuery` helpers also did not
|
|
fire during deliberate world-map route plotting
|
|
|
|
The stronger current theory is that player GPS route selection has two
|
|
front-doors:
|
|
|
|
- quest/objective pins are committed by updating the native journal's tracked
|
|
entry
|
|
- custom/player pins are committed through native mappin tracking
|
|
|
|
Both eventually update native GPS state downstream. Traffic lane data is still
|
|
likely used somewhere in the final route line, but the tested `maxSpeed` and
|
|
lane-exit probability fields are traffic simulation inputs, not the live
|
|
player-GPS edge-cost knobs.
|
|
|
|
## Static GPS Query Candidates
|
|
|
|
Static disassembly of `Cyberpunk2077.exe` found script/native registration
|
|
thunks for `RunGPSQuery` and `UpdateGPSQuery`. The string xrefs themselves are
|
|
registration code, but their registered function pointers lead to more
|
|
interesting native wrappers:
|
|
|
|
```text
|
|
RunGPSQuery string rva: 0x2cd9978
|
|
RunGPSQuery registration xref: 0x147d068
|
|
RunGPSQuery wrapper rva: 0x29bd5ac
|
|
RunGPSQuery helper rva: 0x29bcf14
|
|
|
|
UpdateGPSQuery string rva: 0x2cd9968
|
|
UpdateGPSQuery registration xref: 0x147cfb4
|
|
UpdateGPSQuery wrapper rva: 0x29bd6c8
|
|
UpdateGPSQuery helper rva: 0x29bd254
|
|
```
|
|
|
|
`RunGPSQueryHelper` appears to read source/target vector-ish script arguments,
|
|
resolve a target object/mappin, build temporary bitsets/sets, and call helpers
|
|
near `0x204abdc` and `0x204ac1c`. Its return value is used as a success flag by
|
|
the wrapper.
|
|
|
|
`UpdateGPSQueryHelper` appears related but takes a request type and target. Its
|
|
return value is also used as a success flag.
|
|
|
|
These helpers were hooked with return-value-preserving signatures, not the
|
|
registration thunks:
|
|
|
|
```cpp
|
|
bool RunGPSQueryHelper(void* this_, void* from, void* to, void* debugText,
|
|
void* resultText, float maxDistance);
|
|
bool UpdateGPSQueryHelper(void* this_, uint32_t requestType, void* target,
|
|
void* debugText);
|
|
```
|
|
|
|
These signatures are inferred from the wrapper call sites and Windows x64
|
|
calling convention.
|
|
|
|
Live result: neither helper fired when plotting multiple world-map routes. They
|
|
remain useful static landmarks for GPS-like script APIs, but they are not the
|
|
world-map destination tracking path being exercised by normal player route
|
|
selection.
|
|
|
|
## Mappin Tracking Handoff
|
|
|
|
The first native lead was the script/native mappin tracking path:
|
|
|
|
```text
|
|
TrackMappin string rva: 0x2e82c30
|
|
TrackMappin registration xref: 0x1843eb8
|
|
TrackMappin wrapper rva: 0x27c4ab4
|
|
TrackMappin core rva: 0x27c11d0
|
|
Mappin system resolver rva: 0x02bb840
|
|
|
|
SetMappinTrackingAlternative string rva: 0x2c672e0
|
|
SetMappinTrackingAlternative registration xref: 0x121c194
|
|
SetMappinTrackingAlternative wrapper rva: 0x1246794
|
|
```
|
|
|
|
`TrackMappinCore` resolves a native mappin system through `0x02bb840`. The
|
|
resolver loads an object pointer from `this + 0xa8`, reads the mappin-system
|
|
type/singleton global at `0x342f138`, and calls vtable slot `0x08` on the
|
|
object it loaded.
|
|
|
|
Once the native mappin system is resolved, `TrackMappinCore` branches on whether
|
|
the supplied mappin handle is null:
|
|
|
|
- non-null tracked mappin: call resolved system vtable slot `0x220`
|
|
- null tracked mappin: call resolved system vtable slot `0x228`
|
|
|
|
That looks exactly like "set tracked mappin" versus "clear tracked mappin".
|
|
However, a clean live test disproved it as the normal world-map route plotting
|
|
path: `TrackMappin`, `TrackMappinCore`, and
|
|
`SetMappinTrackingAlternative` did not fire while plotting three map routes.
|
|
|
|
`SetMappinTrackingAlternative` parses two script arguments and calls vtable slot
|
|
`0x418` on its receiver. It is probably related to alternate tracking behavior,
|
|
but static disassembly and live logging have not shown it to be the main route
|
|
planner.
|
|
|
|
## World-Map Mappin Selection Lead
|
|
|
|
The next native lead is the world-map mappin controller block around
|
|
`0x1427c4314`. These functions are still mappin-facing, but they are closer to
|
|
selection, framing, and custom-position route display than the script helper
|
|
above:
|
|
|
|
```text
|
|
FrameMappinPath wrapper rva: 0x27c4314
|
|
FrameMappinPath core rva: 0x27bc1ec
|
|
SetSelectedMappin wrapper rva: 0x27c4a38
|
|
SetSelectedMappin by-id wrapper rva: 0x27c4944
|
|
SetSelectedMappin by-position wrapper rva: 0x27c49c4
|
|
SetSelectedMappin core rva: 0x27c1684
|
|
TrackCustomPositionMappin wrapper rva: 0x27c4aac
|
|
TrackCustomPositionMappin core rva: 0x27c2318
|
|
UntrackCustomPositionMappin wrapper rva: 0x27c4ba0
|
|
Mappin system resolver rva: 0x02bb840
|
|
```
|
|
|
|
`FrameMappinPath` calls the resolved mappin-system vtable slot `0x280`.
|
|
`TrackCustomPositionMappin` calls slot `0x2f0` to create a custom-position
|
|
mappin/route handle and slot `0x1f0` to update an existing one. Those calls are
|
|
currently the most route-like native behavior found in static disassembly.
|
|
|
|
The installed RED4ext probe now logs the wrappers and cores listed above. When
|
|
one of them can resolve the mappin system, it also logs concrete vtable RVAs for
|
|
slots `0x1f0`, `0x220`, `0x228`, `0x250`, `0x280`, `0x2e0`, `0x2f0`, `0x368`,
|
|
and `0x3a8`, then attaches temporary hooks to the route-adjacent slots.
|
|
|
|
Live result: this lead appears to be the hover/selection layer, not the route
|
|
commit layer. In a controlled test window, hovering a world-map icon and then
|
|
moving back to empty map space fired `SetSelectedMappinWrapper` and
|
|
`SetSelectedMappinCore`. Pressing the route/track action on that icon did not
|
|
fire `FrameMappinPath`, `TrackCustomPositionMappin`, `TrackMappin`, the tracked
|
|
mappin slots, or any of the custom-position slots.
|
|
|
|
That pushed the search one layer higher, into the world-map controller script
|
|
methods around tracking an objective or setting a waypoint. Static REDscript
|
|
decompilation now gives a clear high-level route action path:
|
|
|
|
```text
|
|
WorldMapMenuGameController.OnPressInput
|
|
-> HandlePressInput
|
|
-> TryTrackQuestOrSetWaypoint
|
|
-> TrackQuestMappin
|
|
-> JournalManager.TrackEntry
|
|
```
|
|
|
|
For non-quest/player pins the same `TryTrackQuestOrSetWaypoint` function calls
|
|
`TrackMappin`. For custom pins it calls `TrackCustomPositionMappin`, which
|
|
creates or updates a custom-position mappin and then tracks it.
|
|
|
|
Live result: custom waypoint routing fired the native custom-position path:
|
|
|
|
```text
|
|
TrackCustomPositionMappin wrapper/core
|
|
MappinSystem create-custom-position slot 0x2f0
|
|
TrackMappin core
|
|
MappinSystem set-tracked slot 0x220
|
|
```
|
|
|
|
Quest/objective route plotting did not fire those mappin hooks. The script
|
|
decomp explains why: quest pins go through `JournalManager.TrackEntry`, not
|
|
`TrackMappin`.
|
|
|
|
## REDscript Route Surface
|
|
|
|
The decompiled `WorldMapMenuGameController` is useful as an input-routing map,
|
|
not as the planner implementation.
|
|
|
|
Important script observations:
|
|
|
|
- `HandlePressInput` calls `TryTrackQuestOrSetWaypoint` for
|
|
`world_map_menu_track_waypoint`.
|
|
- `TrackQuestMappin` extracts the selected mappin's journal entry and calls
|
|
`JournalManager.TrackEntry`.
|
|
- `UpdateTrackedQuest` reads `JournalManager.GetTrackedEntry`, asks the mappin
|
|
system for quest mappin positions with `GetQuestMappinPositionsByObjective`,
|
|
and updates world-map UI text/position state. It does not compute the GPS
|
|
route.
|
|
- `GPSSystem` is present as a native class, but its REDscript surface is empty.
|
|
- `GPSSettings` is presentation/refresh data: line effects, fixed path offsets,
|
|
refresh intervals, and display length.
|
|
- `NavigationFunctionalTests.GetPathOnNavmesh`, `RunGPSQuery`, and
|
|
`UpdateGPSQuery` exist, but runtime tests showed they are not called by normal
|
|
map route plotting.
|
|
|
|
The remaining route planner target is therefore native code reacting to either
|
|
tracked-journal-entry changes or mappin tracking changes.
|
|
|
|
Native direct-call scanning supports that split:
|
|
|
|
```text
|
|
RunGPSQuery helper RVA 0x29bcf14 direct callers: 1 wrapper caller
|
|
UpdateGPSQuery helper RVA 0x29bd254 direct callers: 1 wrapper caller
|
|
JournalManager.TrackEntry RVA 0x5944fc direct callers: 13
|
|
```
|
|
|
|
The GPS query helpers appear to be exposed helper/test surfaces, not the route
|
|
path used by the world map. `TrackEntry` is now the highest-confidence native
|
|
handoff for quest/objective route plotting.
|
|
|
|
## Journal Listener Fanout
|
|
|
|
`JournalManager.TrackEntry` at RVA `0x5944fc` is now confirmed at runtime as the
|
|
quest/objective route-plot entry point. A controlled test produced route-plot
|
|
calls at `06:32:44`, `06:32:50`, and `06:32:56`, all from return RVA
|
|
`0x26ac34e`, which is the script/native wrapper path for `TrackEntry`.
|
|
|
|
The native `TrackEntry` implementation updates the tracked-entry state and then
|
|
fans out through a listener array:
|
|
|
|
```text
|
|
JournalManager + 0x210: listener pointer array
|
|
JournalManager + 0x21c: listener count
|
|
listener vtable + 0x28: one tracked-entry callback path
|
|
listener vtable + 0x30: another tracked-entry callback path
|
|
listener vtable + 0x50: untrack/default path
|
|
```
|
|
|
|
Before the save is fully loaded the listener count is small. After the world map
|
|
is opened it rises to 254. Most entries use the no-op callback `0x14a700`, but
|
|
the live route test repeatedly exposed this small non-default set:
|
|
|
|
```text
|
|
0xea89a8 / 0xea8958
|
|
0xe63f80 / 0xe63e6c
|
|
0xe63f00 / 0x8d136c
|
|
0x431a34 / 0x55a4e4
|
|
0x14de238
|
|
```
|
|
|
|
Static disassembly of these callbacks suggests the following split:
|
|
|
|
- `0x14a700` is a plain empty return.
|
|
- `0xea89a8` and `0xea8958` are objective/story tracking callbacks; their vtable
|
|
lives beside a `quests/street_stories/.../generic_sts_objective` string.
|
|
- `0x431a34`, `0x55a4e4`, and `0x14de238` are heavy UI/tracker state callbacks.
|
|
- `0xe63f80` and `0xe63e6c` remain the most promising route-refresh callbacks:
|
|
they read nested listener fields at `+0x70` and `+0x78` and call high vtable
|
|
slots such as `0x218`, `0x230`, `0x240`, `0x340`, and `0x348`.
|
|
|
|
The installed probe now hooks those non-default callbacks directly and logs the
|
|
nested owner/service vtables. A later controlled run narrowed the quest route
|
|
handoff further:
|
|
|
|
```text
|
|
JournalManager.TrackEntry
|
|
-> JournalListener::Route callbacks at 0xe63f80 / 0xe63e6c
|
|
-> journal/mappin route bridge candidate at 0x598250
|
|
-> MappinSystem route-event enqueue at 0x13763d8
|
|
-> route-event handler at 0xaa62d0
|
|
-> activate route ref at 0xaa6330
|
|
-> or deactivate route ref at 0x27abd7c
|
|
```
|
|
|
|
The route listener object has two important nested service pointers in the live
|
|
logs:
|
|
|
|
```text
|
|
listener + 0x70: mappin system, vtable 0x14310e300
|
|
listener + 0x78: journal manager, vtable 0x1430f0890
|
|
```
|
|
|
|
The mappin system slot `0x240` points to RVA `0x13763d8`. Static disassembly
|
|
shows it is not the solver. It builds a small queued event with vtable
|
|
`0x14310e1c0`:
|
|
|
|
```text
|
|
event + 0x08: event type byte, value 5
|
|
event + 0x18: route/journal id
|
|
event + 0x20: mappin system pointer
|
|
event + 0x28: active/unactive bool
|
|
```
|
|
|
|
The event handler at `0xaa62d0` reads that bool and calls either `0xaa6330` or
|
|
`0x27abd7c` with the mappin system and a route reference taken from the event
|
|
payload. Both target functions look like active-route state toggles: they look
|
|
up a route ref in a map at `mappinSystem + 0x1a0`, set a byte at route object
|
|
offset `0x84`, then notify observers from the list at `mappinSystem + 0x280`
|
|
through virtual callbacks at offsets `0x48`/`0x50`.
|
|
|
|
Static xrefs also expose a shared route-change helper at `0x27aa2d8`, which
|
|
deactivates an old route ref and activates a new route ref. That is likely the
|
|
common mappin state transition path for both journal/objective routes and
|
|
custom/player pins.
|
|
|
|
The newest controlled run confirms the queued route event timing:
|
|
|
|
```text
|
|
quest route click
|
|
-> JournalManager.TrackEntry from wrapper return RVA 0x26ac34e
|
|
-> MappinSystem route-event enqueue old route active=0
|
|
-> MappinSystem route-event enqueue new route active=1
|
|
-> 11-18 ms later: MappinRouteEvent::Handle
|
|
-> MappinSystem::RouteDeactivate(old route key)
|
|
-> MappinSystem::RouteActivate(new route key)
|
|
-> route observers at system + 0x280
|
|
```
|
|
|
|
The click-to-event delay matched the in-game behavior: the old GPS path
|
|
disappears for a few frames, then the new path appears. That makes the route
|
|
observer fanout the next most useful runtime layer to inspect.
|
|
|
|
The activation/deactivation argument is not an object pointer. It is a 64-bit
|
|
route key. Static disassembly of `0xaa6330` and `0x27abd7c` shows that key being
|
|
looked up in the active-route map at `mappinSystem + 0x1a0`; the resulting map
|
|
entry's `+0x08` field points at the actual route object/smart pointer passed to
|
|
route observers.
|
|
|
|
The first observer callback set seen at runtime is:
|
|
|
|
```text
|
|
observer vtable 0x14310e760: slot 0x48 -> 0xaa6610, slot 0x50 -> 0x27b10c0
|
|
observer vtable 0x143136050: slot 0x48 -> 0xaa6628, slot 0x50 -> 0x295d4a0
|
|
observer vtable 0x143121458: slot 0x48 -> 0xaa63e0, slot 0x50 -> 0x286a85c
|
|
```
|
|
|
|
The installed probe now hooks those callbacks directly and logs the route entry,
|
|
route object, route object vtable slots, and the route object's active byte at
|
|
offset `0x84`.
|
|
|
|
The first observer-object run separated three route-object vtable families:
|
|
|
|
```text
|
|
vtable 0x142b31138: quest/objective route objects, key at +0x40
|
|
vtable 0x142af1510: quest/objective route objects, key at +0x40
|
|
vtable 0x142b30e20: custom/player pin route objects, key at +0x40
|
|
```
|
|
|
|
`RouteActivate` and `RouteDeactivate` really do flip the route object's active
|
|
byte at `+0x84`. The observer callbacks receive the same route entry after that
|
|
flip.
|
|
|
|
Static disassembly of observer0 (`0xaa6610 -> 0xaa6678`) shows it consuming
|
|
route-object virtuals at `+0x1a8`, `+0x188`, `+0x140`, and `+0x198`.
|
|
`+0x198` returns a route enum from a nested descriptor; `+0x188` returns a
|
|
descriptor payload pointer; and `+0x140` is a route-family-specific boolean.
|
|
That observer stores derived route state into buckets under the observer object,
|
|
choosing one bucket only when the enum is `0x8e`.
|
|
|
|
Static disassembly of observer1 (`0xaa6628`) is now the highest-value
|
|
downstream branch. It resolves a route object, then calls a generic system
|
|
getter with a runtime type/global at `.data` RVA `0x342f6a8`, calls the returned
|
|
service's vtable slot `0x220` with `routeObject + 0x8c`, and stores generated
|
|
handles/strings under its inner object at offsets around `+0x180`, `+0x1a0`,
|
|
`+0x1c0`, and `+0x1e0`. The current probe logs that service-owner path and
|
|
`routeObject + 0x8c` so the next runtime test can tell whether observer1 is
|
|
crossing into GPS/path computation or just updating presentation state.
|
|
|
|
The `07:22:14` controlled run confirmed observer1 is in the deliberate route
|
|
click window, not just load-time setup. The three quest route clicks produced
|
|
the expected sequence:
|
|
|
|
```text
|
|
JournalManager.TrackEntry
|
|
MappinSystem.RouteEventEnqueue old active=0 / new active=1
|
|
MappinSystem.RouteDeactivate old route key
|
|
MappinSystem.RouteActivate new route key
|
|
RouteObserver1 deactivate/activate with the matching routeObject + 0x8c route id
|
|
```
|
|
|
|
The observer1 service-owner object was stable across those calls
|
|
(`serviceOwner` vtable `0x142b74ea8`, slot `0x08` target `0x140287c44`).
|
|
Static disassembly of `0x140287c44` shows a generic type-to-service lookup, so
|
|
the next probe hooks that function, filters on the runtime type pointer at
|
|
`0x342f6a8`, logs the returned service object, and dynamically hooks the
|
|
returned service's vtable slot `0x220`. That slot is the immediate next native
|
|
call after the lookup and receives the route id from `routeObject + 0x8c`.
|
|
|
|
The most route-building-looking static candidate currently in this chain is
|
|
`0x5625a4`, called once from the bridge at `0x5984ec`. The bridge resolves
|
|
journal route ids through virtual journal-manager calls, collects several route
|
|
descriptor/data pointers, and passes them to `0x5625a4`. Runtime evidence now
|
|
shows this function firing in a dense load-time/minimap setup burst, not during
|
|
later deliberate map-click route plotting. It may build cached route descriptors
|
|
or GPS display metadata, but it is probably not the direct click-time solver
|
|
entry point.
|
|
|
|
## Native False Positives
|
|
|
|
Static string scans found a tempting traffic/pathfinding cluster around RVA
|
|
`0x512000` with messages such as:
|
|
|
|
```text
|
|
Pathfinding Algorithm Failed
|
|
Find Straight Path Failed
|
|
No Path Found in Traffic
|
|
There's no point found to reach traffic
|
|
```
|
|
|
|
Disassembly around the string xrefs shows those strings being loaded into a
|
|
large constructor/settings/result-description table rather than a solver loop.
|
|
Related functions around `0x50f680`, `0x50fc24`, `0x513430`, and
|
|
`0x513824` clearly touch traffic/path data, but their direct caller context
|
|
references vehicle behavior and stuck-detection settings:
|
|
|
|
```text
|
|
vehicles.common.stuck_detection_check_distance
|
|
vehicles.common.stuck_detection_interval
|
|
DriveState*
|
|
```
|
|
|
|
That cluster is likely autonomous vehicle traffic/path behavior. It may share
|
|
the same road graph as GPS, but it is not yet evidence of the player map-route
|
|
planner.
|
|
|
|
The `GPSSystem/Tick` string is also mostly a profiling/event landmark. Nearby
|
|
code builds profiling scopes and RTTI/type registration scaffolding; it has not
|
|
yet exposed a clean planner function.
|
|
|
|
## The High-Level Shape
|
|
|
|
Cyberpunk appears to split "navigation" into at least two major domains:
|
|
|
|
- pedestrian/NPC navigation over navmesh resources
|
|
- vehicle/GPS routing over generated traffic lane resources
|
|
|
|
The public script/native type dump exposes `worldNavigationScriptInterface` and
|
|
`AINavigationSystem` methods for navmesh path queries, but those are character
|
|
navigation tools. They are not the car GPS. The car GPS system type exists as
|
|
`gamegpsGPSSystem`, but its public RTTI surface does not expose planner methods
|
|
that a REDscript mod can override.
|
|
|
|
That means the practical mod surface is not yet settled. The data-only surface
|
|
has been tested and appears insufficient. The next credible surface is a
|
|
RED4ext hook in the native mappin/GPS handoff, after the concrete target
|
|
function has been identified.
|
|
|
|
## Relevant Game Types
|
|
|
|
The useful RTTI types are:
|
|
|
|
- `gamegpsGPSSystem`
|
|
- `gamegpsSettings`
|
|
- `worldTrafficPersistentResource`
|
|
- `worldTrafficPersistentData`
|
|
- `worldTrafficLanePersistent`
|
|
- `worldTrafficLanePlayerGPSInfo`
|
|
- `worldTrafficLanePersistentFlags`
|
|
|
|
`gamegpsSettings` contains display/refresh settings:
|
|
|
|
- `lineEffectOnFoot`
|
|
- `lineEffectVehicle`
|
|
- `fixedPathOffset`
|
|
- `fixedPortalMappinOffset`
|
|
- `pathRefreshTimeInterval`
|
|
- `lastPlayerNavmeshPositionRefreshTimeIntervalSecs`
|
|
- `maxPathDisplayLength`
|
|
|
|
Those affect GPS presentation and refresh behavior, not edge selection.
|
|
|
|
`worldTrafficPersistentResource` is the key resource type. Its root chunk has
|
|
`data: worldTrafficPersistentData`, which has:
|
|
|
|
- `lanes: array<worldTrafficLanePersistent>`
|
|
- `neighborGroups: array<array<Uint16>>`
|
|
|
|
Each lane has the actual graph metadata:
|
|
|
|
- `outLanes`
|
|
- `inLanes`
|
|
- `outline`
|
|
- `accumulatedLengths`
|
|
- `crowdCreationInfo`
|
|
- `maxSpeed`
|
|
- `deadEndStart`
|
|
- `length`
|
|
- `width`
|
|
- `area`
|
|
- `flags`
|
|
- `subGraphId`
|
|
- `playerGPSInfo`
|
|
- `neighborGroupIndex`
|
|
- `nodeRefHash`
|
|
- `laneNumber`
|
|
- `seqNumber`
|
|
- `isReversed`
|
|
- `roadMaterials`
|
|
- `polygon`
|
|
|
|
`playerGPSInfo` contains:
|
|
|
|
- `subGraphId`
|
|
- `stronglyConnectedComponentId`
|
|
|
|
That strongly suggests the GPS planner has a precomputed connectivity graph and
|
|
uses connected-component/subgraph metadata to reject impossible paths quickly.
|
|
|
|
## Resource Location
|
|
|
|
In this install, the important resource is:
|
|
|
|
```text
|
|
base\worlds\03_night_city\sectors\_generated\traffic\all.traffic_persistent
|
|
```
|
|
|
|
It exists in both the basegame Night City archive and the Phantom Liberty Night
|
|
City archive. For a Phantom Liberty install, the EP1 archive version should win:
|
|
|
|
```text
|
|
archive/pc/ep1/ep1_1_nightcity.archive
|
|
```
|
|
|
|
WolvenKit can extract, serialize, deserialize, and pack this resource on Linux
|
|
through `wolvenkit.cli` 8.18.1.
|
|
|
|
## Lane Flags
|
|
|
|
`worldTrafficLanePersistentFlags`:
|
|
|
|
```text
|
|
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
|
|
```
|
|
|
|
The important flags for GPS weighting are `Road`, `Intersection`, `GPSOnly`, and
|
|
`Highway`. The important flags to avoid touching are `Pavement`, `CrossWalk`,
|
|
`TrafficDisabled`, and `Blockade`.
|
|
|
|
## Observed Lane Distribution
|
|
|
|
The local EP1 `all.traffic_persistent` resource contains `33,952` lanes when
|
|
extracted from the archive's raw compressed segment.
|
|
|
|
Observed `maxSpeed` distribution:
|
|
|
|
```text
|
|
all lanes:
|
|
1: 12852
|
|
15: 12107
|
|
10: 5859
|
|
17: 3027
|
|
14: 96
|
|
8: 8
|
|
9: 2
|
|
4: 1
|
|
|
|
highway lanes:
|
|
17: 2700
|
|
15: 164
|
|
1: 2
|
|
|
|
road lanes:
|
|
15: 11943
|
|
10: 671
|
|
17: 327
|
|
14: 96
|
|
8: 4
|
|
|
|
pavement lanes:
|
|
1: 12848
|
|
10: 5188
|
|
8: 4
|
|
9: 2
|
|
4: 1
|
|
```
|
|
|
|
The values are not real-world mph/kph. They are compact game-scale lane speeds.
|
|
Because highways are already mostly `17` and normal roads are mostly `15`, a
|
|
large value like `90` was expected to be reckless. The first conservative
|
|
archive used:
|
|
|
|
- highway speed target: `25`
|
|
- road speed floor: `15`
|
|
|
|
That changes `2,519` lanes:
|
|
|
|
- `2,429` highway boosts
|
|
- `90` road floor boosts
|
|
|
|
It leaves pavement/crosswalk/blocked/disabled lanes alone.
|
|
|
|
## What The Data Archive Experiment Did
|
|
|
|
The data archive experiment did not replace the pathfinding algorithm.
|
|
|
|
It edited the traffic graph's lane metadata so the native GPS planner might have
|
|
better cost inputs. The original hypothesis was that if the planner factored
|
|
`maxSpeed` into traversal cost in the usual way, then a segment with the same
|
|
physical length but higher `maxSpeed` would become cheaper. That should have
|
|
nudged the native planner toward highways and high-capacity roads without
|
|
hooking the planner itself.
|
|
|
|
Conceptually:
|
|
|
|
```text
|
|
old cost ~= lane.length / lane.maxSpeed
|
|
new cost ~= lane.length / patched_lane.maxSpeed
|
|
```
|
|
|
|
Live testing disproved this as a player-GPS lever. The patched value clearly
|
|
reached the game because traffic speed changed, but player GPS route choice did
|
|
not.
|
|
|
|
## Why Not Just Write A New A*?
|
|
|
|
A standalone A* is easy. Integrating it into Cyberpunk's GPS is the hard part.
|
|
|
|
The game already has:
|
|
|
|
- traffic lane connectivity
|
|
- lane polygons/outlines
|
|
- player lane matching
|
|
- destination lane matching
|
|
- dynamic refresh
|
|
- minimap/world map rendering
|
|
- quest mappin target integration
|
|
- portal/entrance handling
|
|
- route line effects
|
|
|
|
The script-facing `gamegpsGPSSystem` type does not expose hooks for replacing
|
|
that pipeline. A custom planner would still need a way to feed its result back
|
|
into the GPS renderer and route-following systems. Without native RED4ext hooks,
|
|
that is much riskier than patching the resource data the existing pipeline
|
|
already consumes.
|
|
|
|
## Packaging Flow
|
|
|
|
The working flow is:
|
|
|
|
1. Read the archive index and extract the exact compressed segment for resource
|
|
hash `3419764573789342681`.
|
|
2. Decompress that KARK segment with WolvenKit CLI's Oodle command.
|
|
3. Serialize the CR2W resource to JSON.
|
|
4. Patch lane `maxSpeed` values.
|
|
5. Deserialize the patched JSON back to CR2W.
|
|
6. Put the CR2W back under the original resource path.
|
|
7. Pack an archive.
|
|
8. Place the archive in `archive/pc/mod`.
|
|
|
|
Do not use plain `cp77tools extract` for this resource. In this install,
|
|
WolvenKit CLI extracted a `9,984,106` byte CR2W, while the stock archive segment
|
|
declares and decompresses to `10,673,648` bytes. A vanilla control archive built
|
|
from the smaller extraction crashed during load. A vanilla control archive built
|
|
from the raw segment reached the game and the GPS worked normally.
|
|
|
|
The generated archive currently contains exactly:
|
|
|
|
```text
|
|
base\worlds\03_night_city\sectors\_generated\traffic\all.traffic_persistent
|
|
```
|
|
|
|
## Risks And Unknowns
|
|
|
|
The first major finding from in-game testing is that `maxSpeed` is not a useful
|
|
GPS weighting lever. Raising highway lanes as high as `120` did not change GPS
|
|
routes in the test case, but it did make freeway traffic noticeably faster. That
|
|
means `maxSpeed` should be treated as traffic simulation data, not GPS route
|
|
cost data.
|
|
|
|
The actual lane adjacency is not populated in `all.traffic_persistent`; every
|
|
lane's `outLanes` array serialized empty. The adjacency is in:
|
|
|
|
```text
|
|
base\worlds\03_night_city\sectors\_generated\traffic\all.lane_connections
|
|
```
|
|
|
|
That resource is a `worldTrafficPersistentLaneConnectionsResource` with one row
|
|
per lane. Each row has `inLanes` and `outlanes`. Each outgoing connection
|
|
contains `laneIndex`, `exitProbabilityCompressed`, `isSharpAngle`,
|
|
`nextLaneEntryPosition`, and `thisLaneExitPosition`.
|
|
|
|
There is still no explicit edge cost field. A follow-up data-only test patched
|
|
`exitProbabilityCompressed`, strongly favoring road/GPSOnly/highway connections
|
|
that enter or remain on highways and lowering competing non-highway exits. That
|
|
archive changed 145 outgoing connection probabilities across 136 lanes. It also
|
|
loaded and ran, but it did not change the tested player GPS routes.
|
|
|
|
The Oodle library was not available inside the toolbox, so WolvenKit packed with
|
|
its Kraken fallback. A vanilla full-resource control archive loaded in game and
|
|
GPS worked normally, so the earlier crash was caused by bad extracted data rather
|
|
than the Kraken-packed archive container.
|
|
|
|
The patch targets the EP1 traffic resource because Phantom Liberty is installed.
|
|
On a non-PL install, the basegame archive resource would need to be patched
|
|
instead.
|
|
|
|
## Native Async Traffic Route Pipeline
|
|
|
|
The later RED4ext probes moved the active lead away from journal/mappin fanout
|
|
and into the native async traffic route pipeline.
|
|
|
|
The useful runtime path is:
|
|
|
|
```text
|
|
GPS query dispatch 0x70a570
|
|
traffic request enqueue 0x8d17d8
|
|
traffic result take 0x709d5c
|
|
route-record conversion 0x44a398
|
|
route postprocess 0x44830c
|
|
```
|
|
|
|
`0x8d17d8` enqueues work into the traffic route service. `0x709d5c` later takes
|
|
the finished result from the service result table, and `0x44830c` postprocesses
|
|
the traffic result into the path object consumed by the game. Route records
|
|
captured at this stage matched `worldTrafficLanePersistent.nodeRefHash` values
|
|
from `all.traffic_persistent`, which confirms that the displayed/player GPS path
|
|
is backed by the traffic lane graph even though `maxSpeed` and connection
|
|
probability patches did not affect route choice.
|
|
|
|
Later static work found the actual graph-search loop upstream of those result
|
|
records:
|
|
|
|
```text
|
|
route producer 0x44cc7c
|
|
packed-handle resolver 0x44e1a8
|
|
path search loop 0x44f054
|
|
path materializer 0x44eb68
|
|
heuristic distance helper 0x44f7bc
|
|
edge-cost callback 0x44f838
|
|
base provider filter 0x450b08
|
|
filtered provider filter 0x44ff68
|
|
```
|
|
|
|
`0x44f054` is an A*/Dijkstra-style open-list search. It validates packed start
|
|
and target handles, pushes the start node, repeatedly pops the lowest-priority
|
|
state, checks the target handle, traverses adjacent handles from the current
|
|
navigation segment, calls a provider filter through vtable slot `+0x08`, calls
|
|
edge cost through vtable slot `+0x10`, and records the predecessor/cost when a
|
|
neighbor improves.
|
|
|
|
Both the base and filtered cost providers use `0x44f838` for slot `+0x10`.
|
|
That function computes geometric distance between the current and neighbor
|
|
points and multiplies it by:
|
|
|
|
```text
|
|
provider[0x08 + ((neighborPoint[0x13] & 0x3f) * 4)]
|
|
```
|
|
|
|
The base provider constructor at `0x451158` initializes all 64 class
|
|
multipliers to `1.0`. The filtered provider constructor at `0x44b9b4` reuses the
|
|
same edge-cost function and mainly adds route masks/special zones. For normal
|
|
map routes, this means the live GPS edge cost is effectively geometric distance
|
|
over a compact navigation graph, not `lane.length / lane.maxSpeed`.
|
|
|
|
The packed handles resolved by `0x44e1a8` point into `VAND` navigation blobs
|
|
loaded from `base\worlds\03_night_city\_compiled\default\navigation_*.streamingsector`.
|
|
The 0x14-byte point record has:
|
|
|
|
- `+0x00`: adjacency/index field used by the search loop
|
|
- `+0x10`: 16-bit provider mask
|
|
- `+0x12`: small point metadata byte
|
|
- `+0x13`: packed route point class/flags; low six bits are the class used by
|
|
`0x44f838`
|
|
|
|
The helper `tools/analyze_vand_navigation.py` decodes these blobs from
|
|
streamingsector JSON. In the three extracted navigation samples, all blobs were
|
|
version 8, all point masks were `0x0003`, and the dominant classes were
|
|
`1`, `4`, `5`, and `3`. That matches the runtime edge-cost logs. This explains
|
|
why the earlier `all.traffic_persistent` patches changed vehicle simulation but
|
|
did not steer player GPS: the route chooser is walking the baked navigation
|
|
`VAND` graph, then later materializing/copying results that can be joined to
|
|
traffic lane hashes.
|
|
|
|
Static disassembly found a small traffic candidate scorer at `0x8d46cc`:
|
|
|
|
```text
|
|
score ~= sqrt(candidate + 0x08)
|
|
+ 2 * abs(candidate_z-ish - reference_z-ish)
|
|
```
|
|
|
|
It reads lane flags from `[candidate_lane + 0x88]` and branches on
|
|
`query + 0x41`:
|
|
|
|
- `query41 != 0`: pavement lanes (`0x0008`) get a vanilla `+20.0` penalty.
|
|
- `query41 == 0`: road-flagged lanes (`0x0010`) get a vanilla `*1.75`
|
|
penalty and caller `0x8d4630` applies an additional geometry normalization.
|
|
|
|
Only two direct callers reach this scorer:
|
|
|
|
```text
|
|
0x8d4568 -> 0x8d46cc
|
|
0x8d466f -> 0x8d46cc
|
|
```
|
|
|
|
Both iterate 0x30-byte candidate records and keep the lowest score. This makes
|
|
`0x8d46cc` a bounded candidate/projection selector, not the full city-wide graph
|
|
search loop.
|
|
|
|
An experimental hook multiplied the returned score by lane flag:
|
|
|
|
- highway: `0.35`
|
|
- road: `0.70`
|
|
- GPSOnly: `1.35`
|
|
- pavement: `2.75`
|
|
- other: `1.50`
|
|
|
|
Live logging proved the hook was attached and the corrected mode check was
|
|
active:
|
|
|
|
```text
|
|
pavement query41=1 base=37.854 multiplier=2.750 result=104.099
|
|
road query41=1 base=23.986 multiplier=0.700 result=16.790
|
|
highway query41=1 base=39.640 multiplier=0.350 result=13.874
|
|
```
|
|
|
|
However, that detour crashed during the load-time GPS warmup. The crash report
|
|
was an access violation reading `0x71`, and the crash reproduced until the
|
|
RED4ext plugin was disabled. The hook is therefore disabled by default in source
|
|
(`kEnableGpsTrafficEdgeWeightPatch = false`) and the local installed DLL is
|
|
renamed to `EdgeWeightGPS.dll.disabled`.
|
|
|
|
The main takeaway is still valuable, but narrower than first suspected:
|
|
traffic lane flags are available in native route-side endpoint/candidate code,
|
|
and `0x8d46cc` is an excellent landmark. It is not the city-wide graph-cost
|
|
function.
|
|
|
|
The current prototype therefore targets `0x44f838` instead. It generates a
|
|
compact spatial grid from traffic lane polygons/flags and samples that grid
|
|
from the search-state coordinates passed to the edge-cost callback. The first
|
|
test multipliers are:
|
|
|
|
```text
|
|
highway 0.62
|
|
road 1.00
|
|
GPSOnly 1.20
|
|
pavement 1.35
|
|
unknown 1.05
|
|
```
|
|
|
|
This deliberately leaves VAND point class bits untouched. The materialization
|
|
path branches on those bits after search, so rewriting them in data could alter
|
|
route construction or instructions as well as cost.
|
|
|
|
Runtime logs also show that long visible routes are decomposed into many local
|
|
`0x44cc7c` route-producer searches. The tempting world-map caller
|
|
`ret_rva=0x70aa22` mostly produced tiny connector searches in the resolver
|
|
logs, so the first spatial test should patch edge cost across route-producer
|
|
calls instead of gating only on that caller.
|
|
|
|
The spatial edge-cost prototype is now also disproved as the decisive
|
|
world-map GPS weighting surface. In
|
|
`logs/EdgeWeightGPS_spatial_inversion_no_route_change_1845.log`, the plugin
|
|
first ran with highway-free weights (`H=0.05`, all other spatial classes
|
|
`19.0`), then hot-reloaded the exact inverse (`H=19.0`, all others `0.05`).
|
|
The four repeated world-map routes after the flip produced final
|
|
`GPSQueryResultFetch 0x7094b8` 0x28-byte route records that were exact matches
|
|
for the pre-flip routes: identical lane handles, identical packed metadata, and
|
|
identical record counts. Therefore `0x44f838` remains a real local graph edge
|
|
cost callback, but changing it does not change the final selected route records
|
|
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
|
|
|
|
Better versions of this mod could:
|
|
|
|
- tune the `0x40bb40` solver multiplier after live A/B testing confirms visible
|
|
route changes
|
|
- inspect and, if needed, patch `0x40bb00` so reverse/special progress branches
|
|
receive the same road-class policy as forward expansion
|
|
- 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
|
|
safer caller-side hook
|
|
- decode enough of the `VAND` graph to join navigation points to traffic lane
|
|
polygons and highway flags
|
|
- extract the full Night City navigation streamingsector set into workspace
|
|
storage and summarize it with `tools/analyze_vand_navigation.py`
|
|
- keep the generated spatial grid as a local-search diagnostic, but do not
|
|
treat its multipliers as a proven world-map route weighting knob
|
|
- add route-mode gating if the spatial edge-cost hook affects pedestrians,
|
|
autodrive, or other local navigation too broadly
|
|
- inspect `GPSOnly` connector lanes if a later native trace proves they are
|
|
consumed by the player GPS
|
|
- build district-specific presets if a real data-cost surface is found
|
|
- generate a diff report of every changed lane with flags, length, speed, and
|
|
graph component
|
|
- ship multiple archives only if a data patch is proven to affect routing
|