1+ //! Implementation of diff-chaining to allow any version of Beat Saber to be downgraded to any other, assuming there is a diff
2+ //! from each version to the previous version
3+ //!
4+ //! This module also allows more "direct" diffs to be created for faster downgrading, say several versions at once.
5+ //! A breadth-first-search is used to determine the shortest route (smallest number of diffs) in the database to downgrade one version to another.
6+
7+ use std:: { collections:: { HashMap , VecDeque } , fs:: OpenOptions , io:: { BufReader , BufWriter , Read } , path:: { Path , PathBuf } } ;
8+
9+ use log:: info;
10+ use mbf_res_man:: { models:: { Diff , VersionDiffs } , res_cache:: ResCache } ;
11+ use anyhow:: { Result , Context , anyhow} ;
12+ use mbf_res_man:: external_res;
13+ use mbf_zip:: ZIP_CRC ;
14+ use std:: ffi:: OsStr ;
15+
16+ use crate :: downloads;
17+
18+ // Gets a map of all accessible Beat Saber versions from the given version
19+ // Key is Beat Saber version, value is the sequence of diffs that need to be applied to reach that version.
20+ // Always returns the sequences with the smallest number of diffs
21+ pub fn get_all_accessible_versions ( res_cache : & ResCache , from_version : & str ) -> Result < HashMap < String , Vec < VersionDiffs > > > {
22+ let diff_index_edges = get_diff_index_graph ( res_cache)
23+ . context ( "Loading diff index" ) ?;
24+
25+ // Holds the path of diffs used to reach each Beat Saber version
26+ let mut predecessor_map: HashMap < String , Vec < VersionDiffs > > = HashMap :: new ( ) ;
27+ predecessor_map. insert ( from_version. to_owned ( ) , Vec :: new ( ) ) ; // No diffs needed to reach the current version.
28+
29+ let mut queue = VecDeque :: new ( ) ;
30+ queue. push_back ( from_version. to_string ( ) ) ;
31+
32+ // Apply breath first search
33+ // TODO: This does tons of copying though it doesn't really matter much since there aren't too many versions
34+ while let Some ( curr_ver) = queue. pop_front ( ) {
35+ if let Some ( edges) = diff_index_edges. get ( & curr_ver) {
36+ for diff in edges {
37+ if !predecessor_map. contains_key ( & diff. to_version ) {
38+ let mut path = predecessor_map. get ( & curr_ver)
39+ . expect ( "Current version should always have a path" ) . clone ( ) ;
40+ path. push ( diff. clone ( ) ) ;
41+
42+ predecessor_map. insert ( diff. to_version . clone ( ) , path) ;
43+ queue. push_back ( diff. to_version . clone ( ) ) ;
44+ }
45+ }
46+ }
47+ }
48+
49+ predecessor_map. remove ( from_version) ; // Don't want to show this version in the list of downgrade versions
50+ Ok ( predecessor_map)
51+
52+ }
53+
54+ // Loads the diff index as a graph, where each entry in the HashMap lists the edges from each node (Beat Saber versions are nodes)
55+ fn get_diff_index_graph ( res_cache : & ResCache ) -> Result < HashMap < String , Vec < VersionDiffs > > > {
56+ let diff_index = mbf_res_man:: external_res:: get_diff_index ( res_cache)
57+ . context ( "Fetching downgrading information" ) ?;
58+
59+ let mut diff_index_edges: HashMap < String , Vec < VersionDiffs > > = HashMap :: new ( ) ;
60+ for diff in diff_index {
61+ if let Some ( accessible) = diff_index_edges. get_mut ( & diff. from_version ) {
62+ accessible. push ( diff) ;
63+ } else {
64+ diff_index_edges. insert ( diff. from_version . to_owned ( ) , vec ! [ diff] ) ;
65+ }
66+ }
67+
68+ Ok ( diff_index_edges)
69+ }
70+
71+ // Downgrades the APK file at `temp_path` and the OBB files at `obb_backup_paths`.
72+ // Determines the sequence of diffs to apply automatically.
73+ // The destination APK is written to `temp_path` and the destination OBBs are written to the same directory as the current OBBs.
74+ // Returns the paths to the downgraded OBB files for restoring once Beat Saber is reinstalled.
75+ pub fn get_and_apply_diff_sequence ( from_version : & str , to_version : & str ,
76+ temp_path : & Path , temp_apk_path : & Path , obb_backup_paths : Vec < PathBuf > ,
77+ res_cache : & ResCache )
78+ -> Result < Vec < PathBuf > > {
79+ info ! ( "Working out diff sequence for {from_version} --> {to_version}" ) ;
80+ let diff_sequences = get_all_accessible_versions ( res_cache, from_version)
81+ . context ( "Determining diff sequence" ) ?;
82+ let diffs = diff_sequences. get ( to_version)
83+ . ok_or ( anyhow ! ( "No diff sequence found for version. Why did the frontend let us select it?!" ) ) ?;
84+
85+ apply_diff_sequence ( diffs, temp_path, & temp_apk_path, obb_backup_paths)
86+ . context ( "Downgrading" )
87+ }
88+
89+ // Downgrades the APK file at `temp_path` and the OBB files at `obb_backup_paths`.
90+ // The destination APK is written to `temp_path` and the destination OBBs are written to the same directory as the current OBBs.
91+ // Returns the paths to the downgraded OBB files for restoring once Beat Saber is reinstalled.
92+ pub fn apply_diff_sequence ( diffs : & [ VersionDiffs ] , temp_path : & Path ,
93+ temp_apk_path : & Path ,
94+ mut obb_backup_paths : Vec < PathBuf > ) -> Result < Vec < PathBuf > > {
95+ info ! ( "DOWNGRADING BEAT SABER: This may take a LONG time" ) ;
96+
97+ for ( i, diff) in diffs. iter ( ) . enumerate ( ) {
98+ info ! ( "Applying diffs set {}/{} ({} --> {})" , i + 1 , diffs. len( ) , diff. from_version, diff. to_version) ;
99+
100+ obb_backup_paths = apply_version_diff ( diff, temp_path, & temp_apk_path, obb_backup_paths)
101+ . context ( "Applying diff" ) ?;
102+ }
103+
104+ Ok ( obb_backup_paths)
105+ }
106+
107+ // Downgrades one version of Beat Saber to another, including the APK file and all OBB files
108+ // Passed the path to the temporary APK and paths to each of the existing OBBs for downgrading.
109+ fn apply_version_diff ( diffs : & VersionDiffs , temp_path : & Path ,
110+ temp_apk_path : & Path , obb_backup_paths : Vec < PathBuf > ) -> Result < Vec < PathBuf > > {
111+ // Download the diff files
112+ let diffs_path = temp_path. join ( "diffs" ) ;
113+ std:: fs:: create_dir_all ( & diffs_path) . context ( "Creating diffs directory" ) ?;
114+ info ! ( "Downloading diffs" ) ;
115+ download_diffs ( & diffs_path, & diffs) . context ( "Downloading diffs" ) ?;
116+
117+ // Copy the APK to temp, downgrading it in the process.
118+ info ! ( "Downgrading APK" ) ;
119+ apply_diff (
120+ // If there is already a "downgraded" APK, then we have already applied one diff, so we downgrade THIS APK to the next version.
121+ & temp_apk_path,
122+ & temp_apk_path,
123+ & diffs. apk_diff ,
124+ & diffs_path,
125+ )
126+ . context ( "Applying diff to APK" ) ?;
127+
128+ let mut dest_obb_paths = Vec :: new ( ) ;
129+ for obb_diff in & diffs. obb_diffs {
130+ // Find the OBB matching the filename in the diff
131+ let existing_obb = obb_backup_paths. iter ( )
132+ . find ( |p| p. file_name ( ) == Some ( OsStr :: new ( & obb_diff. file_name ) ) )
133+ . ok_or ( anyhow ! ( "No obb file {} found - is the diff index wrong" , obb_diff. file_name) ) ?;
134+
135+ // Determine a suitable destination path.
136+ let obbs_folder = existing_obb. parent ( ) . unwrap ( ) ;
137+ let dest_obb = obbs_folder. join ( & obb_diff. output_file_name ) ;
138+
139+ apply_diff ( & existing_obb, & dest_obb, obb_diff, & diffs_path)
140+ . context ( "Applying diff to OBB" ) ?;
141+ std:: fs:: remove_file ( existing_obb) . context ( "Deleting old OBB" ) ?; // Save storage space!
142+ dest_obb_paths. push ( dest_obb) ;
143+ }
144+
145+
146+
147+ // Delete diffs when we're done to avoid using too much storage.
148+ std:: fs:: remove_dir_all ( diffs_path) ?;
149+
150+ Ok ( dest_obb_paths)
151+ }
152+
153+ // Loads the file from from_path into memory, verifies it matches the checksum of the given diff,
154+ // applies the diff and then outputs it to to_path
155+ // `from_path` and `to_path` can be the same if you like. I give you permission.
156+ fn apply_diff ( from_path : & Path , to_path : & Path , diff : & Diff , diffs_path : & Path ) -> Result < ( ) > {
157+ let diff_content = read_file_vec ( diffs_path. join ( & diff. diff_name ) )
158+ . context ( "Diff could not be opened. Was it downloaded" ) ?;
159+
160+ let patch = qbsdiff:: Bspatch :: new ( & diff_content) . context ( "Diff file was invalid" ) ?;
161+
162+ let file_content = read_file_vec ( from_path) . context ( "Reading original file from disk" ) ?;
163+
164+ // Verify the CRC32 hash of the file content.
165+ info ! ( "Verifying installation is unmodified" ) ;
166+ let before_crc = ZIP_CRC . checksum ( & file_content) ;
167+ if before_crc != diff. file_crc {
168+ return Err ( anyhow ! ( "File CRC {} did not match expected value of {}.
169+ Your installation is corrupted, so MBF can't downgrade it. Reinstall Beat Saber to fix this issue!
170+ Alternatively, if your game is pirated, purchase a legitimate copy of the game." , before_crc, diff. file_crc) ) ;
171+ }
172+
173+ // Carry out the downgrade
174+ info ! ( "Applying patch (This step may take a few minutes)" ) ;
175+ let mut output_handle = BufWriter :: new (
176+ OpenOptions :: new ( )
177+ . truncate ( true )
178+ . create ( true )
179+ . read ( true )
180+ . write ( true )
181+ . open ( to_path) ?,
182+ ) ;
183+ patch. apply ( & file_content, & mut output_handle) ?;
184+
185+ // TODO: Verify checksum on the result of downgrading?
186+
187+ Ok ( ( ) )
188+ }
189+
190+ // Downloads the deltas needed for downgrading with the given version_diffs.
191+ // The diffs are saved with names matching `diff_name` in the `Diff` struct.
192+ fn download_diffs ( to_path : impl AsRef < Path > , version_diffs : & VersionDiffs ) -> Result < ( ) > {
193+ for diff in version_diffs. obb_diffs . iter ( ) {
194+ info ! ( "Downloading diff for OBB {}" , diff. file_name) ;
195+ download_diff_retry ( diff, & to_path) ?;
196+ }
197+
198+ info ! ( "Downloading diff for APK" ) ;
199+ download_diff_retry ( & version_diffs. apk_diff , to_path) ?;
200+
201+ Ok ( ( ) )
202+ }
203+
204+ // Attempts to download the given diff DIFF_DOWNLOAD_ATTEMPTS times, returning an error if the final attempt fails.
205+ fn download_diff_retry ( diff : & Diff , to_dir : impl AsRef < Path > ) -> Result < ( ) > {
206+ let url = external_res:: get_diff_url ( diff) ;
207+ let output_path = to_dir. as_ref ( ) . join ( & diff. diff_name ) ;
208+
209+ downloads:: download_file_with_attempts ( & crate :: get_dl_cfg ( ) , & output_path, & url)
210+ . context ( "Downloading diff file" ) ?;
211+ Ok ( ( ) )
212+ }
213+
214+ // Reads the content of the given file path as a Vec
215+ fn read_file_vec ( path : impl AsRef < Path > ) -> Result < Vec < u8 > > {
216+ let handle = std:: fs:: File :: open ( path) ?;
217+
218+ let mut file_content = Vec :: with_capacity ( handle. metadata ( ) ?. len ( ) as usize ) ;
219+ let mut reader = BufReader :: new ( handle) ;
220+ reader. read_to_end ( & mut file_content) ?;
221+
222+ Ok ( file_content)
223+ }
0 commit comments