Bootflow
The client's boot state machine: its 79 steps, how a step runs, and how it fails.
The bootflow is the client’s boot state machine. It takes the game from the first frame to a player standing in the world. It has 79 named steps. Each step waits on something, then asks the machine to move on.
Step names are the game’s own strings. Function and field names are descriptive labels. For what Sunrise answers at each step, see From launch to orbit.
The step table
The step names sit in one table of 79 string pointers, indexes 0 to 78, ended by a null. The table lists the boot sequence in order.
The table looks like several name lists joined together. The split below is unverified:
indexes
group
0 to 49
bootflow, setup, activity and bubble-host steps
50 to 57
idle, then the transitioning states
58 to 70
error states
71 to 78
Activity Host state names, not bootflow steps
No known code uses a step index above 39.
Main steps, start to in-world
These are the steps a boot passes through on the way to the world. “Waits on” is what holds the step before it hands off. “Not read” means the name and its place are known, but what the step waits on is not.
name
waits on
0
pregame
not read
1
bootflow:media_check
not read
2
bootflow:bootload
not read
3
bootflow:start
not read
4
bootflow:platform_signin
not read
5
bootflow:platform_account_warnings
not read
6
bootflow:enumerate_content_rights
not read
7
bootflow:account_signin
not read in detail
16
bootflow:content_check
installed content; one DLC wait fails after 2 s
21
bootflow:package_registration
not read
22
bootflow:bap_signin
19 tasks; two are package loads (patchable bootstrap, investment globals)
23
bootflow:investment_signin
six tasks; task 0 finishes last and waits for the account’s investment data
24
bootflow:profile_setup
the player clicking through the startup settings screens
25
bootflow:prepare_for_orbit
a slice-set load, a status, then two network port checks
26
bootflow:rejoin_activity
nothing remote; its first state comes from the profile data
27
character:signin
character select or creation, and the chosen character’s data
28
cleanup
its own teardown tasks
29
setup:orbit
its handoff conditions; picks the destination on entry
30
setup:activity_session_creation
the activity session and the matchmaking config reply
31
setup:matchmaking
the matchmaking composition check
32
setup:activity_host_setup
activity slot 0 ready: the host request and join_request both answered
33
setup:prologue_intro_loading
the activity package load and the activity’s name from activity message 1
34
setup:orbit_outro
the orbit fly-away cinematic; no tasks and no time limit
35
setup:activity_world_transition
the world change; needs the bubble count and slice sets
36
activity:initial_slice_set_loading
eleven tasks: loading screen, slice-set load, region data
37
activity:physics_join
a travel cinematic, if one is armed; capped at 15 s
38
activity:in_world
nothing while the activity runs; lifetime states 6, 7 and 10 start the post-game chain
39
activity:watch_video
not read
A boot goes 7 -> 16 -> 21. It skips steps 8 to 15 and 17 to 20. Those are account, content and queue gates: entitlement_warning, sms_validation, free_license_limit_exceeded, excessive_mtx_debt, eula, upsell, email, confirm_account_transfer, content_install, insufficient_content_space, illegal_dlc_media and queuing.
Steps 40 to 49 are the bubble_host:* steps. They are assumed to run when this client hosts a bubble.
For the activity steps from 29 on, see Activities and destinations.
Step 28 in a failure
Step 28 cleanup is where almost every failure sends the machine. Of the 69 places that raise a failure, 61 name step 28. So “step 28” in a failure says almost nothing about where it happened. The reason code carries that.
How a step is described
A step has two parts: a descriptor and a step object.
The descriptor is a table of handler pointers in read-only data. It is 9 or 14 slots long. Unused slots point at an empty stub.
struct BootStepDescriptor { /* 0x48 or 0x70 bytes */
void (*destroy)(void *step); /* +0x00 slot 0 */
void (*construct)(void *step); /* +0x08 slot 1 */
void (*unused)(void); /* +0x10 slot 2, empty stub */
void (*enter)(void *step); /* +0x18 slot 3 */
void (*exit)(void *step); /* +0x20 slot 4, or empty stub */
int (*can_advance)(void *step); /* +0x28 slot 5, same in every step */
void (*update)(void *step); /* +0x30 slot 6 */
void *filler[2]; /* +0x38 slots 7 and 8 */
/* longer descriptors carry more slots */
};
Slot 5 is CanAdvance, the same function in every step. It checks the step’s blocker bits. Slot 6 is the step’s own Update, which runs each frame.
Descriptors are not at a fixed stride from each other. The manager’s constructor assigns each step its descriptor.
The step object
All steps live inline in one manager object of about 20 KB. Each step object starts with its descriptor pointer.
struct BootStep { /* size varies per step */
const BootStepDescriptor *desc; /* +0x00 */
uint64_t blocker_mask; /* +0x08 bits CanAdvance checks */
uint8_t pad[0x20];
uint8_t wait_object[]; /* +0x30 inline task block or listener; starts with a vtable */
/* step-specific fields follow */
};
The object at +0x30 is stored inline, not as a pointer. What it is depends on how the step waits:
- A task-driven step (22, 23, 28) holds a task block. Steps 30 to 38 run the same task engine. Step 38 keeps its block at
+0x40. - An event-driven step (24, 27) holds a listener. The step posts a UI screen, the screen raises an event, and the listener moves the step’s own state.
The task block
The number of tasks differs per step. Step 23 has 8 task slots and runs 6. Step 30 has 6 tasks, step 33 has 2, step 36 has 11 and step 37 has 1. Four bit masks follow the task entries.
struct BootTaskEntry { /* 32 bytes */
uint64_t prereq_mask; /* +0x00 tasks that must finish first; low byte used */
void (*start)(void); /* +0x08 */
int (*poll)(struct TaskStatus *s); /* +0x10 returns 0 to 3, as TaskStatus.state */
void (*on_step)(void *step); /* +0x18 */
};
struct TaskStatus { /* 16 bytes, built on the stack per call */
int32_t state; /* +0x00 0 requested, 1 started, 2 failed, 3 done */
int64_t elapsed_ms; /* +0x08 */
};
struct BootTaskBlock { /* n = the step's task count */
void *vtable; /* +0x00 */
BootTaskEntry tasks[n]; /* +0x08 */
uint64_t requested; /* +0x08 + 32 * n; +0x108 when n is 8 */
uint64_t started;
uint64_t failed;
uint64_t completed;
uint64_t time[n]; /* start time, then duration */
};
A step is done when requested == completed. So requested ^ completed is the set of tasks still holding it. A task that is not requested reads as state -1.
The client logs each finished task, for example world_controller:task_manager: Completed task 'ENUM(2)' after '909ms'. Task names print as ENUM(n) because the build has no task name table.
Current step and goal step
The manager holds two step numbers:
field
meaning
current step
the step running now
goal step
the step the machine is heading for
The machine walks from the current step toward the goal. A step does not have to name its successor. If the goal is past it and CanAdvance passes, the machine walks on.
Fireteam and posse sessions also carry their own goal step. A goal change is refused while a group host is behind the local step, or while a fireteam join is in progress.
The step loop
The loop below is a model of the known parts. The manager’s own tick function is unverified.
void BootFlow_Tick(BootFlowManager *mgr)
{
BootStep *step = mgr->steps[mgr->current];
step->desc->update(step); /* may raise a goal change */
if (mgr->goal == mgr->current)
return; /* nothing to do */
if (!step->desc->can_advance(step))
return; /* a blocker holds the step */
int next = BootFlow_NextStepToward(mgr->current, mgr->goal);
step->desc->exit(step);
mgr->current = next;
step = mgr->steps[next];
step->desc->enter(step);
log("state_manager: Entering state '%s' for reason '%s'.",
BootFlow_GetStepName(next), BootFlow_GetReasonName(mgr->goal_reason));
}
CanAdvance walks up to 41 blocker bits. Each set bit calls a check. A check returns 0 or 1 to let the step go. A return of 2 fails the step into cleanup:
int BootFlow_Step_CanAdvance(BootStep *step)
{
for (int bit = 0; bit < 41; bit++) {
if (!(step->blocker_mask & (1ull << bit)))
continue;
int reason = -1;
int r = BootFlow_CheckBlocker(step, bit, &reason);
if (r != 0 && r != 1) {
BootFlow_ReportFailure(28, reason); /* goal := cleanup */
return 0;
}
}
return 1;
}
BootFlow_ReportFailure
BootFlow_ReportFailure(step, reason) raises every step change, not only failures. The name is a label and is too narrow. The function sets the goal step.
int BootFlow_ReportFailure(uint32_t step, uint32_t reason)
{
char why[256]; /* size not read */
if (!BootFlow_GoalChange_Veto(step, reason, why)) {
/* refused: log, at most once per 30 s for the same text */
log("world_controller:state_manager: State '%s' requested for reason '%s', "
"but we can't: '%s'.", step_name(step), reason_name(reason), why);
return 0;
}
return BootFlow_SetGoalStep(mgr, step, reason); /* goal := step */
}
Two things follow:
- The client logs only when the change is refused. On the normal path it writes the goal and logs nothing.
- A thin wrapper passes reason 0. Steps use it to hand off to their successor. Its argument is the destination step, not the step that called it.
Reading a (step, reason) pair
Read step as where the client is now heading. Read reason as what went wrong. Only the call site names the actual check.
Reason names are blank in this build. Every reason prints as unavailable. Each reason slot does carry a unique 32-bit value. It may be a hash of the lost name. That is unverified.
The reason numbers are assumed to come in blocks by subsystem. The sites that raise each range suggest this grouping:
reasons
apparent owner
3 to 11
early bootflow
23, 24, 54 to 66
session and bubble-host lifecycle
121 to 128
platform sign-in, entitlement and license
130 to 181
content, account and investment sign-in
273 to 303
license enumeration; 303 is an Investment-channel latch
335 to 361
activity and world setup
Common failures
Every row here sets the goal to step 28 cleanup.
reason
where
guard
4
matchmaking
the composition check fails, for example a solo player counted as a “big party”
9
setup:orbit
a join wait passes its time limit
16
steps 33 to 38
the step’s own time budget runs out; the budget values are not readable statically
55
steps 33 and 36
a task fails; for step 33 often the activity package load
56
prepare_for_orbit
the step’s status lands on “failed”
57
setup:orbit
an unnamed local check
104
steps 36 to 38
no bytes arrive on the Activity Host link for 20.5 s
124 to 128
early sign-in
the content or entitlement fetch returns an error; 124 is the default
174
investment_signin
a blocker fires, for example an investment reply that cannot be decoded
175
investment_signin
task 0 runs past its time limit, about 30 s
176 to 180
investment_signin
the first queuez error, codes 2 to 6, as code + 174
181, 303
investment_signin
one of two latches on the queuez manager is set
queuez is the game’s name for the channel that delivers Investment state, bdLobby service 123. It is not a login queue. Its error codes are on Network stack.
Reason 175 has more than one producer. A blocker can also raise it, under other steps. The number alone does not name the site.
Step 23 never fails with a nonzero reason of its own. A pair like (23, 175) cannot come from a direct call.
A step can also hang with no failure at all. Step 26 picks its first state from the account’s profile data on entry. If that data is missing, the step sits in a state its update has no case for. It does nothing every frame and reports nothing.
Open questions
- What steps 0 to 7, 21 and 39 wait on.
- Whether the step table is several lists joined, and what steps 40 to 49 do.
- The manager’s own tick function.
- Whether the 32-bit reason values are hashes of the lost reason names.
Related pages
- From launch to orbit – what Sunrise answers at each step.
- Activities and destinations – steps 29 to 39.
- The Activity Host protocol – the messages steps 33 to 36 wait on.
- Packages – the content steps 2, 22 and 33 load.
