diff --git a/docs/development/ubisoft-launch.md b/docs/development/ubisoft-launch.md index 0fb49d23b..f7321f4d7 100644 --- a/docs/development/ubisoft-launch.md +++ b/docs/development/ubisoft-launch.md @@ -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: / + "/" + , 4: /, 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: }}}` 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. diff --git a/tools/ubisoft-launcher/src/launcher.rs b/tools/ubisoft-launcher/src/launcher.rs index c58a69c8c..3d5fcac4c 100644 --- a/tools/ubisoft-launcher/src/launcher.rs +++ b/tools/ubisoft-launcher/src/launcher.rs @@ -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 { + 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 { 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 { 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, 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, Option)> { 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"[..]))); + } } diff --git a/tools/ubisoft-launcher/src/main.rs b/tools/ubisoft-launcher/src/main.rs index 6677a4a07..425522e63 100644 --- a/tools/ubisoft-launcher/src/main.rs +++ b/tools/ubisoft-launcher/src/main.rs @@ -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()) { diff --git a/tools/ubisoft-launcher/src/win.rs b/tools/ubisoft-launcher/src/win.rs index aa08c76a7..e6953f46d 100644 --- a/tools/ubisoft-launcher/src/win.rs +++ b/tools/ubisoft-launcher/src/win.rs @@ -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 { + 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.