Skip to content

Starting an offline raid natively (no menu UI)

From the aowlspt repository — docs/NATIVE_RAID_START.md

Published from aoughwl/aowlspt with only its links repaired. It was written for someone with the checkout open, so it is terse and points at source files by path — browse the tree as you read. A few of the documents it cites were held back from publication and are not here.

The manual · The aowlspt section

Offline, static analysis only. The game was not run and nothing was deployed.

Instruments. tools/il2cpp_resolve.py (verbs find / type / bytes / shared), tools/fldoff.py (fields, String self-check PASSED: _stringLength@0x10, _firstChar@0x14), and a scratch capstone disassembler over Resolver.code_bytes with an RVA→method-name map built from every image's methodPointers. Inputs: D:/Games/Tarkov/GameAssembly.dll, .cache/global-metadata.dec.dat. All addresses are RVAs (imagebase 0x180000000); runtime = GameAssemblyBase + RVA.

Everything labelled measured was read out of the binary/metadata in this pass. Everything else is labelled inferred.


1. The call chain (measured)

[UI]  MatchmakerOperation::ShowMatchmakerSideSelection      0xA2E100  unique
        -> ShowMatchmakerLocationSelection                  0xA31480  unique
        -> ShowMatchmakerOfflineRaidScreen                  0xA30F80  unique
        -> ShowMatchmakerAcceptScreen / InsuranceScreen
        -> MatchmakerOperation::OnReadyPressed              0xA312E0  unique
             fires event MatchmakerOperation.OnReadyToStartMatching (@+0x38)
[APP] TarkovApplication::<MainMenu>g__OnReadyToStartMatching|191_1  0x988A10 unique
        -> TarkovApplication::OnReadyToStartMatchingAsync   0x983830  unique
             (state machine <OnReadyToStartMatchingAsync>d__195::MoveNext 0x9CC7B0)
             *** THE BRANCH ***
        -> TarkovApplication::LocalGameMatching             0x984170  unique
             (state machine <LocalGameMatching>d__199::MoveNext 0x9C3810)
             -> ShowTimeHasComeScreen 0x982C50
             -> LoadMapAndData        0x984620
             -> ObjectsFactory::LoadBundlesAndCreatePools
             -> GamePrepare           0x9848B0
             -> LocalGameCreate       0x986330   <- LocalGame instance is built here

TarkovApplication::InternalStartGame(string gameMap, bool isLocalGame, bool isBotEnabled) @ 0x978150 (unique) exists and is tempting, but it is not on this path — nothing in the matchmaker chain calls it (measured: it does not appear in the MoveNext disassembly of either state machine). It is the dev/command-line entry. Not recommended without a separate study.

The offline switch, measured byte-for-byte

In <OnReadyToStartMatchingAsync>d__195::MoveNext:

009ccb81  inc   dword ptr [rbx + 0x1e4]      ; TarkovApplication.CurrentTotalRaidNum++
009ccb87  mov   rax, qword ptr [rbx + 0xd8]  ; this->_raidSettings
009ccb97  cmp   dword ptr [rax + 0x44], 1    ; RaidSettings.RaidMode == ERaidMode.Local
009ccb9b  je    0x9cccc0                     ; -> LOCAL branch
...       (fallthrough)                      ; -> NetworkGameMatching 0x984360
009cccc9  movups xmm0, [rax + 0x50]          ; copy RaidSettings.TimeAndWeatherSettings
009cccf0  lea   rdx, [rsp + 0x70]            ; arg1 = &TimeAndWeatherSettings (28-byte struct, byref)
009cccf5  mov   rcx, rbx                     ; this
009cccea  xor   r9d, r9d                     ; MethodInfo* = NULL
009ccced  xor   r8d, r8d                     ; inTransition = false
009cccf8  call  0x984170                     ; LocalGameMatching

RaidSettings.RaidMode == ERaidMode.Local (1) is the whole offline gate, not IsPveOffline. This matches the reported symptom (fact #263/#246): with RaidMode left at Online (0) the client runs NetworkGameMatching and the load is cancelled.

Also measured here: the game itself passes MethodInfo* = NULL to LocalGameMatching, so a NULL trailing arg is correct — no shared generic involved.


2. The target method

Call EFT.TarkovApplication::OnReadyToStartMatchingAsync().

RVA0x983830
VA0x180983830
Sectionil2cpp (generated code)
SharednessUNIQUE, owners = 1 (il2cpp_resolve.py shared 0x983830)
Signature (derived from metadata, rid 50674)System.Threading.Tasks.Task OnReadyToStartMatchingAsync()instance, zero declared parameters
Native frameRCX = TarkovApplication*, RDX = const MethodInfo* (may be NULL)
Prologue (16 bytes)40 53 48 83 EC 70 80 3D 0A 57 73 06 00 48 8B D9
Thunk shapereal body (push rbx; sub rsp,0x70; cmp cctor-flag; mov rbx,rcx) — not the C2 00 00 universal empty stub

It is async, so it returns a Task immediately and the work continues over subsequent frames. Call it from the Unity main thread (the TarkovApplication::Update bridge @ 0x977B10) — it touches UI and the job scheduler.

Smaller alternative if you want to skip the branch entirely: LocalGameMatching @ 0x984170 (unique, prologue 48 89 5C 24 08 48 89 74 24 10 48 89 7C 24 18 55), frame RCX=this, RDX=&TimeAndWeatherSettings(28 bytes), R8=inTransition(bool), R9=MethodInfo*(NULL). Not preferred: it skips the JobScheduler::SetForceMode and raid-counter setup that OnReadyToStartMatchingAsync does first.


3. Argument construction plan

OnReadyToStartMatchingAsync takes no arguments. Everything comes out of state we must set first. All offsets below are measured via fldoff.py, and the three marked ✓ are independently confirmed by the compiled accessor bytes.

3.1 TarkovApplication (type 7933)

FieldOffsetNote
_raidSettings0xD8get_CurrentRaidSettings@0x691340 is literally 48 8B 81 D8 00 00 00 C3
_localMatchmakerOperation0x100
_menuOperation0x128only the ONLINE branch reads it
<CurrentTotalRaidNum>0x1E4incremented by the method itself

The instance is a MonoBehaviourSingleton; the host already reaches it (the Update bridge at 0x977B10 is TarkovApplication::Update, so RCX there is the instance). Walk from that, do not re-derive a singleton pointer.

3.2 EFT.RaidSettings (type 6557) — what we must write

FieldOffsetValue for "offline Woods, PMC"
<Side>k__BackingField0x20 ✓ (set_Side@0x6D7FE0 = 89 51 20 C3)ESideType.Pmc = 0
LocationId (string)0x30"Woods" via il2cpp_string_new
<RaidMode>k__BackingField0x44 ✓ (set_RaidMode@0x6D8720 = 89 51 44 C3)ERaidMode.Local = 1
SelectedDateTime0x40enum EDateTime (type not resolvable by that name; enumerate before writing)
TimeAndWeatherSettings0x50, 28 bytes inlinealready valid from the ctor; leave alone
BotSettings0x70set by .ctor(); leave alone
WavesSettings0x88set by .ctor(); leave alone
IsPveOffline0xA0not the offline gate; do not rely on it
_selectedLocation0xA8the hard one — see below
_locationSettings0xB0JsonType.LocationSettings, INITONLY, source of Locations

Enums (measured, fldoff.py enum): ERaidMode {Online=0, Local=1, Coop=2, Narrate=3}; ESideType {Pmc=0, Savage=1, Random=2}; EPlayersSpawnPlace {SamePlace=0, DifferentPlaces=1, AtTheEndsOfTheMap=2}.

The two enum writes are plain 4-byte stores (the setters prove it) — no call needed, but read-validate-then-write.

3.3 _selectedLocation MUST already be a real Location — measured

<LocalGameMatching>d__199::MoveNext:

009c3e8a  mov rdi, [r14 + 0xd8]           ; _raidSettings
009c3eaf  cmp qword ptr [rdi + 0xa8], 0   ; _selectedLocation
009c3eb7  je  0x9c3eed                    ; skip refinement if null
009c3ed3  mov rcx, [rdi + 0xa8]
009c3eda  call JsonType.LocationLevelVariants::ResolveByPlayerLevel
009c3ee8  call EFT.RaidSettings::set_SelectedLocation   ; 0x6D7FF0

LocalGameMatching only refines an already-chosen Location; it never resolves LocationIdLocation. If _selectedLocation is null the map load later has nothing to load. So we must supply it.

Source: RaidSettings._locationSettings@0xB0JsonType.LocationSettings.locations@0x10, a Dictionary<string, JsonType.Location> (measured).

Two ways to get locations["Woods"]:

  • Preferred — walk the Dictionary's entries array by raw offsets and compare the key string in place. Avoids calling Dictionary<K,V>::TryGetValue, which IS a shared generic and therefore needs a real MethodInfo* we cannot fabricate. Cost: the Dictionary<string,Location> instantiated layout is not reachable offline (all Il2CppGenericClass.cached_class are null in the file), so the entries-array offset has to be borrowed from a live object or a verified header.
  • Assign with RaidSettings::set_SelectedLocation @ 0x6D7FF0 (unique; prologue 8B 05 4A E5 9D 06 4C 8D 15 23 11 A0 06 48 89 91). Frame: RCX=raidSettings, RDX=Location*, R8=MethodInfo*(NULL). Do not raw-write this field — the compiled setter contains a GC write barrier; a bare pointer store can be collected out from under the raid.

3.4 MatchmakerOperation.MatchmakerPlayersController must be non-null — measured

009c3ff6  call TarkovApplication::get_MatchmakerOperation  ; 0x977360
009c3ffb  test rax, rax / je -> NRE throw
009c4004  mov  rcx, [rax + 0xa0]     ; <MatchmakerPlayersController>k__BackingField
009c400b  test rcx, rcx / je -> NRE throw
009c4022  call MatchmakerPlayersController::SetMatchingAbortAvailability

Both are hard null checks that jump to the NRE throw helper. If MatchmakerOperation@+0xA0 is null, LocalGameMatching throws inside the async state machine and the raid silently never starts.

Good news, also measured: get_MatchmakerOperation@0x977360 (unique) is self-healing — if _menuOperation@0x128 is null (or its +0x118 is), it constructs an OfflineInventoryController + MatchmakerOperation from the backend session and stores it at +0x100. What it does not do is populate +0xA0; that is set by set_MatchmakerPlayersController@0x691060 from the matchmaker screens.

So the sequence must be either (a) let the UI get as far as side-select once (which is exactly where our automation already reaches), or (b) call MatchmakerOperation::ShowMatchmakerSideSelection() @ 0xA2E100 (unique, prologue 40 53 48 83 EC 70 80 3D 5E B1 68 06 00 48 8B D9; frame RCX=matchmakerOperation, RDX=MethodInfo*) and wait frames for its async body before firing OnReadyToStartMatchingAsync.

Confirmed on the same path: MatchmakerOperation._raidSettings@0x40 is the same object the ready check reads (OnReadyPressed @0xA312E0 does mov rax,[rbx+0x40]; cmp dword [rax+0x44],2 — RaidMode again, and cmp dword [rax+0x20],0 — Side). Set the fields on whichever of TarkovApplication._raidSettings / MatchmakerOperation._raidSettings you verify are the same pointer; they should be, but check.


4. Sharedness verdicts (all from il2cpp_resolve.py ... shared)

MethodRVAVerdict
TarkovApplication::OnReadyToStartMatchingAsync0x983830UNIQUE (1)
TarkovApplication::LocalGameMatching0x984170UNIQUE (1)
TarkovApplication::NetworkGameMatching0x984360UNIQUE (1)
TarkovApplication::LocalGameCreate0x986330UNIQUE (1)
TarkovApplication::InternalStartGame0x978150UNIQUE (1)
TarkovApplication::get_MatchmakerOperation0x977360UNIQUE (1)
TarkovApplication::<MainMenu>g__OnReadyToStartMatching|191_10x988A10UNIQUE (1)
MatchmakerOperation::ShowMatchmakerSideSelection0xA2E100UNIQUE (1)
MatchmakerOperation::ShowMatchmakerOfflineRaidScreen0xA30F80UNIQUE (1)
MatchmakerOperation::OnReadyPressed0xA312E0UNIQUE (1)
RaidSettings::set_SelectedLocation0x6D7FF0UNIQUE (1)
<OnReadyToStartMatchingAsync>d__195::MoveNext0x9CC7B0UNIQUE (1)
<LocalGameMatching>d__199::MoveNext0x9C3810UNIQUE (1)
TarkovApplication::get_CurrentRaidSettings0x691340SHARED (36) — safe to call, never detour
RaidSettings::set_RaidMode0x6D8720SHARED (12) — trivial mov [rcx+0x44],edx
RaidSettings::set_Side0x6D7FE0SHARED (67) — trivial mov [rcx+0x20],edx

None of the unique targets matched the universal empty-body stub (C2 00 00).


5. Feasibility verdict

INCONCLUSIVE, leaning PASS.

What is settled offline (measured):

  • the exact method to call, its RVA, that it is unique, that it has a real body, its 16 prologue bytes, and that it takes no arguments;
  • that the offline/online decision is one comparison, RaidSettings@+0x44 == 1;
  • that the game itself passes a NULL MethodInfo* into the local branch, so no shared-generic problem applies to the call we want to make;
  • that _selectedLocation@+0xA8 and MatchmakerOperation@+0xA0 are hard prerequisites, with the exact instructions that prove it.

What is not settled offline, and the experiment that settles each:

  1. Is TarkovApplication._raidSettings@0xD8 non-null at the main menu, and is _locationSettings@0xB0 populated there? Experiment: live inspector, read $app @0xd8 then @0xb0 as ptr at the main menu, before pressing anything. Read-only, one batch.
  2. The Dictionary<string, Location> instantiated layout. Not reachable offline (every cached_class in the file is null). Experiment: on the live dictionary at _locationSettings+0x10, dump 0x40 bytes and identify the entries/count pair against a known key.
  3. Is MatchmakerOperation@+0xA0 already non-null at the point our automation currently stalls (the SELECT YOUR CHARACTER / PVE ZONE screen)? If yes, the whole side-select screen can be skipped by just writing RaidSettings and calling 0x983830. Experiment: at that stalled screen, read $app @0x100 @0xa0 (and $app @0x128) as ptr. This is the single highest- value measurement in this document — it decides whether we need step (b) of §3.4 at all.
  4. Whether the profile/side selection the stuck screen performs writes anything else (e.g. which profile GetProfileForLocalGame@0x986130 returns). Not analysed. Experiment: read _raidSettings field-by-field on a session that reached the raid through the UI, and diff against a session that did not.

I would not ship a call to 0x983830 before measurement 1 and 3. Everything else can be built behind a default-OFF flag in the meantime.


6. Calling notes

  • Hidden trailing const MethodInfo* applies to every call here.OnReadyToStartMatchingAsync: RCX=this, RDX=MethodInfo*. set_SelectedLocation: RCX=this, RDX=Location*, R8=MethodInfo*. LocalGameMatching: RCX=this, RDX=&TimeAndWeather, R8=inTransition, R9=MethodInfo*.
  • NULL MethodInfo* is correct here — measured directly from the game's own call site (xor r9d,r9d before call 0x984170). No shared generic is on this path. The one place a shared generic would bite is Dictionary<string,Location>::TryGetValue; §3.3 avoids it by walking.
  • Byte-verify all 16 prologue bytes against the startup snapshot before any call, per the standing rule. The prologue tables above are from GameAssembly.dll on disk, so they are the pre-patch truth.
  • Run on the Unity main thread via the Update bridge; one aowl_p_p_seh around the whole body; flag-gated default OFF; self-disable after N faults.

aoughwl — self-hosted platform for things n stuff. Contact / Support on Discord for access to the private backends.