Files
2077-gps-mod/docs/traffic-system-debrief.md
T

34 KiB

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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.

Future Improvements

Better versions of this mod could:

  • identify the broader async traffic route search/expansion loop around the 0x8d25xx to 0x8d49xx cluster
  • disassemble the full map-route submit/result chain around 0x8d20d4, 0x70a42c, 0x70a570, 0x7094b8, and the 0x520783 result drain to find the route selection or copy step before final records are emitted
  • 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