fix(ubisoft): answer Orbit, login and process-only check-ins (#2460)

This commit is contained in:
K11MCH1 authored and GitHub committed 2026-11-11 07:49:18 -05:00
1 parent 45ef6b7d1a
commit b1a410ccd2
4 files changed
+131 -23

No files matched your search

+11 -4
View File
@@ -33,11 +33,12 @@ followed by a proto2 message, except on `uplay_service_ipc_pipe_`.
| Pipe | Client | Exchange |
|---|---|---|
| `game_start_ipc_pipe_` | `UbisoftGameLauncher.exe` | `game_starter.Upstream{1:{1:{1: build, 2: product, 10: exe, 11: arguments, ...}}}` → `Downstream{2:{1:{1:0 (overlay off), 2:0, 3: <root>/ + "/" + <exe under root>, 4: <root>/, 8:3, 9:{1:{1,113,0,1,0} 1:{3,123,0,0,0}}}}}` |
| `uplay_api_process_ipc_pipe_` | game | `Req.initProcessReq{1: product, 2: game pid, 5: env set}` → `Rsp.initProcessRsp(13){1:1, 3:0 (overlay off), 4:3, 6:0}`. Kept open. Answering it is what releases the starter (below) |
| `uplay_ipc_pipe_` | game (`uplay_r1.dll`) | `Req.initReq(1)` → `Rsp.initRsp(1)`, answered every time it is asked. `Req.installerReq(13)` with `initInstallerReq` → `Rsp.installerRsp(10){1:{1:1, 2:"", 3: language}}` |
| `uplay_api_process_ipc_pipe_` | game | `Req.initProcessReq{1: product, 2: game pid, 5: env set}` → `Rsp.initProcessRsp(13){1:1, 3:0 (overlay off), 4:3, 6:0}`. Kept open. Answering it is what releases the starter (below). Far Cry 3 Blood Dragon sends only `{2: pid, 5:1}`; a missing pid is taken from the pipe's client process |
| `uplay_ipc_pipe_` | game (`uplay_r1.dll`) | `Req.initReq(1)` → `Rsp.initRsp(1)`, answered every time it is asked. `Req.installerReq(13)` with `initInstallerReq` → `Rsp.installerRsp(10){1:{1:1, 2:"", 3: language}}`. `Req.userReq(11).getCredentialsReq(1){1: id}` → `Rsp.userRsp(8).getCredentialsRsp(1){1: id, 2: username, 3: "" (password), 4: userId, 5: email}` |
| `uplay_aux_ipc_pipe_` | `uplay_aux_r1(64).dll` (Denuvo) | `{1: rid, 2: init}` → `{1: rid, 2:{1:1, 2: pid, 3:3367, 3:3368, 3:3962}}`; `{1: rid, 3:{2:{2: challenge}}}` → `{1: rid, 3:{2:{2:1, 3:0, 4: game token, 5:""}}}` |
| `uplay_service_ipc_pipe_` | Ubisoft helpers | Server sends `{3:{1:1, 3:{1: <hwscore path>}}}` once, then only reads, unframed |
| `upc_api_process_ipc_pipe_standard`, `uplay_protocol_ipc_pipe_`, `uplay_overlay_ipc_pipe_`, `orbit_ipc_pipe_`, `uplay_crash_reporter_ipc_pipe_` | — | Held open, never answered |
| `orbit_ipc_pipe_` | game (`ubiorbitapi_r2.dll`) | `mg.protocol.orbitdll` from the DLL's own `proto_orbitdll/OrbitDll.proto`. `Req{1: id, 12: init{1: product}}` → `Rsp{1: id, 12:{1: save folder, 2:1}}`; `getLoginDetails(2)` → `{1: username, 2:"", 3:""}`; `getOsi(3){1: sub}` → `{2:1}` (failed, no online-services pipe) |
| `upc_api_process_ipc_pipe_standard`, `uplay_protocol_ipc_pipe_`, `uplay_overlay_ipc_pipe_`, `uplay_crash_reporter_ipc_pipe_` | — | Held open, never answered |
### The session (`InitRsp`)
@@ -60,6 +61,12 @@ runtime folder right after the session arrives, whatever the session says; witho
with `UplayAPI [ERROR]: Startup failed: cannot load uplay library` (Assassin's Creed Chronicles: China
then sits on a black screen and aborts with code 3 after about 160 s), so the runtime includes it.
### Orbit titles
Older titles such as Far Cry 3 Blood Dragon (205) load `ubiorbitapi_r2.dll` next to `uplay_r1.dll` and ask
`orbit_ipc_pipe_` for `init` before anything else; unanswered, the game waits forever on a black screen. The DLL
keeps savegames itself as `*.save` files in the folder `init` returns, the same save folder as field 17 above.
## Start sequence
1. `droiddeck-upc.exe` creates the pipes and an auto-reset event marked inheritable, then starts the
@@ -86,5 +93,5 @@ titles without Denuvo never open the aux pipe.
- R2 (`UPC_*`) titles use `uplay_r2.dll`; their pipe traffic has not been captured, so whether the
same server answers them is unverified.
- Orbit (`ubiorbitapi_r2`) titles.
- Orbit's online-services pipe (`getOsi` is declined) and Orbit titles other than Far Cry 3 Blood Dragon.
- Achievements, friends, overlay, cloud saves.
+85 -13
View File
@@ -11,14 +11,16 @@ pub const API_PROCESS: &str = "uplay_api_process_ipc_pipe_";
pub const SESSION: &str = "uplay_ipc_pipe_";
pub const AUX: &str = "uplay_aux_ipc_pipe_";
pub const SERVICE: &str = "uplay_service_ipc_pipe_";
pub const ORBIT: &str = "orbit_ipc_pipe_";
/// Pipes Connect creates that the game may open; they are held open without answers.
pub const QUIET: &[&str] = &[
"upc_api_process_ipc_pipe_standard",
"uplay_protocol_ipc_pipe_",
"uplay_overlay_ipc_pipe_",
"orbit_ipc_pipe_",
"uplay_crash_reporter_ipc_pipe_",
];
/// `GetOsiRsp.Status.Failed`: no online-services pipe is offered.
const OSI_FAILED: u64 = 1;
/// The add-on list Connect returns from the aux init; the game echoes it back.
const AUX_ADDONS: [u64; 3] = [3367, 3368, 3962];
/// `OverlayInjectionMethod_None`.
@@ -175,6 +177,12 @@ impl Config {
Msg::new().msg(1, body.bytes(23, b"")).0
}
/// `Rsp.userRsp.getCredentialsRsp`: the account without its password, which DroidDeck never holds.
pub fn credentials(&self, request_id: u64) -> Vec<u8> {
let rsp = Msg::new().uint(1, request_id).text(2, &self.username).text(3, "").text(4, &self.user_id).text(5, &self.email);
Msg::new().msg(8, Msg::new().msg(1, rsp)).0
}
/// `Rsp.installerRsp.initInstallerRsp`.
pub fn installer(&self) -> Vec<u8> {
Msg::new().msg(10, Msg::new().msg(1, Msg::new().uint(1, 1).msg(2, self.chunks()).text(3, &self.language))).0
@@ -193,6 +201,26 @@ impl Config {
pub fn service_hello(&self) -> Vec<u8> {
Msg::new().msg(3, Msg::new().uint(1, 1).msg(3, Msg::new().text(1, HWSCORE))).0
}
/// `mg.protocol.orbitdll.Rsp`. The DLL keeps its own `*.save` files in the folder `init` returns.
pub fn orbit(&self, body: &[u8]) -> Option<(Vec<u8>, String)> {
let f = pb::fields(body)?;
let id = pb::num(&f, 1)?;
let rsp = Msg::new().uint(1, id);
if let Some(init) = pb::sub(&f, 12) {
let product = pb::fields(init).and_then(|i| pb::num(&i, 1)).unwrap_or(0);
return Some((rsp.msg(12, Msg::new().text(1, &self.saves).uint(2, 1)).0, format!("init {id} for {product} answered")));
}
if pb::has(&f, 2) {
let login = Msg::new().text(1, &self.username).text(2, "").text(3, "");
return Some((rsp.msg(2, login).0, format!("login details {id} answered")));
}
if let Some(osi) = pb::sub(&f, 3) {
let sub = pb::fields(osi).and_then(|o| pb::num(&o, 1)).unwrap_or(0);
return Some((rsp.msg(3, Msg::new().uint(2, OSI_FAILED)).0, format!("osi {id} for {sub} declined")));
}
None
}
}
/// Requests answered with only their id and a result: (`Req` field, its request) to (`Rsp` field, its
@@ -233,26 +261,33 @@ pub enum SessionReq {
Init,
Installer,
ProductList(u64),
Credentials(u64),
Other,
}
pub fn session_request(body: &[u8]) -> SessionReq {
match pb::fields(body) {
Some(f) if pb::sub(&f, 13).and_then(pb::fields).is_some_and(|i| pb::has(&i, 1)) => SessionReq::Installer,
Some(f) if pb::has(&f, 1) => SessionReq::Init,
Some(f) => match pb::sub(&f, 23).and_then(pb::fields).and_then(|p| pb::sub(&p, 1).and_then(pb::fields)) {
Some(list) => SessionReq::ProductList(pb::num(&list, 1).unwrap_or(0)),
None => SessionReq::Other,
},
_ => SessionReq::Other,
let Some(f) = pb::fields(body) else { return SessionReq::Other };
let leaf = |req, sub| pb::sub(&f, req).and_then(pb::fields).and_then(|r| pb::sub(&r, sub).and_then(pb::fields));
if pb::sub(&f, 13).and_then(pb::fields).is_some_and(|i| pb::has(&i, 1)) {
SessionReq::Installer
} else if pb::has(&f, 1) {
SessionReq::Init
} else if let Some(get) = leaf(11, 1) {
SessionReq::Credentials(pb::num(&get, 1).unwrap_or(0))
} else if let Some(list) = leaf(23, 1) {
SessionReq::ProductList(pb::num(&list, 1).unwrap_or(0))
} else {
SessionReq::Other
}
}
/// The product and game process the game announces on the api-process pipe.
pub fn announced(body: &[u8]) -> Option<(u32, u32)> {
/// The product and game process announced on the api-process pipe, each when the game names it
/// (Far Cry 3 Blood Dragon sends only its process).
pub fn announced(body: &[u8]) -> Option<(Option<u32>, Option<u32>)> {
let f = pb::fields(body)?;
let r = pb::fields(pb::sub(&f, 2)?)?;
Some((pb::num(&r, 1)? as u32, pb::num(&r, 2)? as u32))
let named = |n| pb::num(&r, n).map(|v| v as u32).filter(|v| *v != 0);
Some((named(1), named(2)))
}
pub enum AuxReq<'a> {
@@ -350,9 +385,46 @@ mod tests {
let rsp = pb::fields(pb::sub(&pb::fields(pb::sub(&pb::fields(&answer).unwrap(), 5).unwrap()).unwrap(), 1).unwrap()).unwrap();
assert_eq!((pb::num(&rsp, 1), pb::num(&rsp, 2)), (Some(0x20000), Some(1)));
assert!(acknowledge(&Msg::new().msg(1, Msg::new().uint(1, 1)).0).is_none());
assert_eq!(announced(&Msg::new().msg(2, Msg::new().uint(1, 109).uint(2, 4242).uint(5, 1)).0), Some((109, 4242)));
assert_eq!(announced(&Msg::new().msg(2, Msg::new().uint(1, 109).uint(2, 4242).uint(5, 1)).0), Some((Some(109), Some(4242))));
assert_eq!(announced(&[0x12, 0x05, 0x10, 0x88, 0x04, 0x28, 0x01]), Some((None, Some(520))));
assert!(matches!(session_request(&[0x5a, 0x04, 0x0a, 0x02, 0x08, 0x00]), SessionReq::Credentials(0)));
let activate = Msg::new().uint(1, 2).msg(3, Msg::new().msg(2, Msg::new().bytes(2, b"challenge").uint(3, 0)));
assert!(matches!(aux_request(&activate.0), AuxReq::Activate(2, b"challenge")));
assert!(matches!(aux_request(&Msg::new().uint(1, 1).msg(2, Msg::new().uint(1, 109)).0), AuxReq::Init(1)));
}
#[test]
fn orbit_init_answers_with_the_save_folder() {
let (rsp, what) = config().orbit(&[0x08, 0x01, 0x62, 0x03, 0x08, 0xcd, 0x01]).unwrap();
assert_eq!(what, "init 1 for 205 answered");
let f = pb::fields(&rsp).unwrap();
assert_eq!(pb::num(&f, 1), Some(1));
let init = pb::fields(pb::sub(&f, 12).unwrap()).unwrap();
assert_eq!(pb::sub(&init, 1), Some(&b"Z:/saves/u1/109/"[..]));
assert_eq!(pb::num(&init, 2), Some(1));
}
#[test]
fn orbit_login_details_carry_no_password_and_osi_is_declined() {
let c = config();
let (rsp, _) = c.orbit(&Msg::new().uint(1, 2).bytes(2, b"").0).unwrap();
let f = pb::fields(&rsp).unwrap();
let login = pb::fields(pb::sub(&f, 2).unwrap()).unwrap();
assert_eq!((pb::sub(&login, 1), pb::sub(&login, 2), pb::sub(&login, 3)), (Some(&b"name"[..]), Some(&b""[..]), Some(&b""[..])));
let (rsp, what) = c.orbit(&Msg::new().uint(1, 3).msg(3, Msg::new().uint(1, 7)).0).unwrap();
assert_eq!(what, "osi 3 for 7 declined");
let osi = pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 3).unwrap()).unwrap();
assert_eq!((pb::num(&osi, 2), pb::has(&osi, 1)), (Some(OSI_FAILED), false));
assert!(c.orbit(&Msg::new().uint(1, 4).0).is_none());
}
#[test]
fn credentials_name_the_account_without_a_password() {
let rsp = config().credentials(7);
let user = pb::fields(pb::sub(&pb::fields(&rsp).unwrap(), 8).unwrap()).unwrap();
let get = pb::fields(pb::sub(&user, 1).unwrap()).unwrap();
assert_eq!(pb::num(&get, 1), Some(7));
assert_eq!((pb::sub(&get, 2), pb::sub(&get, 3), pb::sub(&get, 4), pb::sub(&get, 5)),
(Some(&b"name"[..]), Some(&b""[..]), Some(&b"u1"[..]), Some(&b"e@x"[..])));
}
}
+29 -6
View File
@@ -123,13 +123,17 @@ mod service {
let mut frames = Frames::new(&pipe);
let Some(req) = frames.next() else { return };
match launcher::announced(&req) {
Some((product, pid)) => {
state.say(&format!("api_process: product {product} is process {pid}"));
if pid != 0 {
state.game.store(pid, Ordering::SeqCst);
Some((product, named)) => {
let product = product.unwrap_or(state.cfg.product);
match named.or_else(|| pipe.client_pid()) {
Some(pid) => {
state.say(&format!("api_process: product {product} is process {pid}"));
state.game.store(pid, Ordering::SeqCst);
}
None => state.say(&format!("api_process: product {product} named no process")),
}
}
None => state.say(&format!("api_process: {} B announcement not understood", req.len())),
None => state.say(&format!("api_process: {} B announcement not understood {}", req.len(), hex(&req, 32))),
}
send(&pipe, &state.cfg.init_process());
state.event.set();
@@ -150,6 +154,10 @@ mod service {
send(&pipe, &state.cfg.installer());
state.say("session: installer init answered");
}
SessionReq::Credentials(id) => {
send(&pipe, &state.cfg.credentials(id));
state.say(&format!("session: credentials {id} answered"));
}
SessionReq::ProductList(id) => {
send(&pipe, &state.cfg.product_list(id));
state.say(&format!("session: product list {id} answered"));
@@ -217,6 +225,20 @@ mod service {
while pipe.read(&mut chunk).is_some() {}
}
fn orbit(state: &State, pipe: Handle) {
let mut frames = Frames::new(&pipe);
while let Some(req) = frames.next() {
match state.cfg.orbit(&req) {
Some((answer, what)) => {
send(&pipe, &answer);
state.say(&format!("orbit: {what}"));
}
None => state.say(&format!("orbit: {} B request {} not handled", req.len(), hex(&req, 24))),
}
}
state.say("orbit: closed");
}
fn quiet(state: &State, name: &str, pipe: Handle) {
state.say(&format!("{name}: connected"));
let mut chunk = vec![0u8; 65536];
@@ -240,6 +262,7 @@ mod service {
launcher::SESSION => session(&s, pipe),
launcher::AUX => aux(&s, pipe),
launcher::SERVICE => service(&s, pipe),
launcher::ORBIT => orbit(&s, pipe),
_ => quiet(&s, name, pipe),
});
}
@@ -283,7 +306,7 @@ mod service {
started: Instant::now(),
});
state.say(&format!("product {} on {}*", state.cfg.product, state.prefix));
for name in [launcher::GAME_START, launcher::API_PROCESS, launcher::SESSION, launcher::AUX, launcher::SERVICE]
for name in [launcher::GAME_START, launcher::API_PROCESS, launcher::SESSION, launcher::AUX, launcher::SERVICE, launcher::ORBIT]
.into_iter()
.chain(launcher::QUIET.iter().copied())
{
+6
View File
@@ -67,6 +67,7 @@ extern "system" {
fn GetExitCodeProcess(h: HANDLE, code: *mut u32) -> i32;
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> HANDLE;
fn ProcessIdToSessionId(pid: u32, session: *mut u32) -> i32;
fn GetNamedPipeClientProcessId(pipe: HANDLE, pid: *mut u32) -> i32;
fn GetCurrentProcessId() -> u32;
}
@@ -134,6 +135,11 @@ impl Handle {
pub fn set(&self) {
unsafe { SetEvent(self.0) };
}
pub fn client_pid(&self) -> Option<u32> {
let mut pid = 0;
(unsafe { GetNamedPipeClientProcessId(self.0, &mut pid) } != 0 && pid != 0).then_some(pid)
}
}
/// A new instance of the named pipe, waiting for its client; `None` when it cannot be created.