Last active
March 31, 2025 15:41
-
-
Save Demon000/ced8860266d6aa97ceeee2336a846ea0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| from ctypes import Structure, c_char, c_double, c_uint32, sizeof | |
| import hashlib | |
| from io import BufferedReader | |
| from itertools import combinations | |
| import struct | |
| import sys | |
| from typing import Dict, List, Optional, TextIO, Tuple | |
| from enum import Enum | |
| SHA_SIZE = 0x14 | |
| MAX_SUPPORTED_VERSION = 5 | |
| class EffectId(int, Enum): | |
| CLICK = 0 | |
| DOUBLE_CLICK = 1 | |
| TICK = 2 | |
| THUD = 3 | |
| POP = 4 | |
| HEAVY_CLICK = 5 | |
| RINGTONE_1 = 6 | |
| RINGTONE_2 = 7 | |
| RINGTONE_3 = 8 | |
| RINGTONE_4 = 9 | |
| RINGTONE_5 = 10 | |
| RINGTONE_6 = 11 | |
| RINGTONE_7 = 12 | |
| RINGTONE_8 = 13 | |
| RINGTONE_9 = 14 | |
| RINGTONE_10 = 15 | |
| RINGTONE_11 = 16 | |
| RINGTONE_12 = 17 | |
| RINGTONE_13 = 18 | |
| RINGTONE_14 = 19 | |
| RINGTONE_15 = 20 | |
| TEXTURE_TICK = 21 | |
| def int_str(self): | |
| return int(self), self.name.lower() | |
| class DecryptionUnit(Structure): | |
| _fields_ = [ | |
| ("first", c_uint32), | |
| ("second", c_uint32), | |
| ] | |
| class ConfigHeader(Structure): | |
| _fields_ = [ | |
| ("version", c_uint32), | |
| ("size", c_uint32), | |
| ("unknown0", c_double), | |
| ("client", c_char * 20), | |
| ("client_item", c_char * 20), | |
| ("device_type", c_char * 20), | |
| ("vibrator_type", c_char * 20), | |
| ] | |
| class ConfigParamsV1(Structure): | |
| _fields_ = [ | |
| ("unknown0", c_double), | |
| ("unknown1", c_double), | |
| ("unknown2", c_double), | |
| ("unknown3", c_double), | |
| ("unknown4", c_double), | |
| ("unknown5", c_double), | |
| ("unknown6", c_double), | |
| ("unknown7", c_double), | |
| ("unknown8", c_double), | |
| ("unknown9", c_double), | |
| ("unknown10", c_double), | |
| ] | |
| class ConfigParamsV2(Structure): | |
| _fields_ = [ | |
| ("interrupt_protect_time", c_double), | |
| ] | |
| class ConfigParamsV3(Structure): | |
| _fields_ = [ | |
| ("unknown1", c_double), | |
| ] | |
| class ConfigParamsV4(Structure): | |
| _fields_ = [ | |
| ("jnd_value", c_double), | |
| ("jnd_step", c_double), | |
| ] | |
| class ConfigMore(Structure): | |
| _fields_ = [ | |
| ("unknown0", c_uint32), | |
| ("size", c_uint32), | |
| ("length", c_uint32), | |
| ("unknown1", c_uint32), | |
| ] | |
| class ConfigEffectArrays(Structure): | |
| _fields_ = [ | |
| ("unknown0", c_uint32), | |
| ("unknown1", c_uint32), | |
| ("length", c_uint32), | |
| ("unknown2", c_uint32), | |
| ] | |
| class ConfigEffects(Structure): | |
| _fields_ = [ | |
| ("unknown0", c_uint32), | |
| ("size", c_uint32), | |
| ("unknown1", c_uint32), | |
| ] | |
| class ConfigEffect(Structure): | |
| _fields_ = [ | |
| ("id", c_uint32), | |
| ("effect_level", c_uint32), | |
| ("size", c_uint32), | |
| ("unknown0", c_uint32), | |
| ] | |
| class Effect: | |
| def __init__(self): | |
| self.effect_level_data: Dict[int, bytearray] = {} | |
| def add_effect(self, effect_level: int, effect_data: bytearray): | |
| self.effect_level_data[effect_level] = effect_data | |
| @property | |
| def name(self): ... | |
| class PrebakedEffect(Effect): | |
| def __init__(self, group: int, effect_id: int): | |
| super().__init__() | |
| self.group = group | |
| self.effect_id = effect_id | |
| @property | |
| def name(self): | |
| return f"{self.group}_{self.effect_id}" | |
| class ComposedEffect(Effect): | |
| def __init__(self, name: str): | |
| super().__init__() | |
| self.__name = name | |
| @property | |
| def name(self): | |
| return self.__name | |
| effect_ids_map_type = Dict[EffectId, Tuple[int, int]] | |
| def decrypt_data(data: bytes): | |
| CONST_1 = 0x7C66FDF2 | |
| CONST_2 = 0x11FD7ED1 | |
| CONST_3 = 0x2F64EEF7 | |
| CONST_4 = 0x518FEEFE | |
| CONST_5 = -0xC881070 | |
| CONST_5_ITERATIONS = 16 | |
| CONST_6 = 0x60C88107 | |
| CONST_7 = 0x10 | |
| MASK = 0xFFFFFFFF | |
| offset = 0 | |
| decrypted_data = bytearray() | |
| while offset < len(data): | |
| rolling_const_5 = CONST_5 | |
| unit = DecryptionUnit.from_buffer_copy(data, offset) | |
| offset += sizeof(unit) | |
| first = unit.first | |
| second = unit.second | |
| for _ in range(CONST_5_ITERATIONS): | |
| second = second - ( | |
| CONST_1 + first * CONST_7 | |
| ^ first + rolling_const_5 | |
| ^ CONST_2 + (first >> 5) | |
| ) | |
| second &= MASK | |
| first = first - ( | |
| CONST_3 + second * CONST_7 | |
| ^ rolling_const_5 + second | |
| ^ CONST_4 + (second >> 5) | |
| ) | |
| first &= MASK | |
| rolling_const_5 += CONST_6 | |
| rolling_const_5 &= MASK | |
| decrypted_data += bytes(DecryptionUnit(first=first, second=second)) | |
| return decrypted_data | |
| def unpack_int8_t(value): | |
| bytes_value = value.to_bytes(1, byteorder="little") | |
| return struct.unpack("<b", bytes_value)[0] | |
| def offsetof_class(cls, member): | |
| return getattr(cls, member).offset | |
| def offsetof(st, member): | |
| return offsetof_class(st.__class__, member) | |
| def print_fields_offsets(st, start_offset=0): | |
| print(st.__class__.__name__) | |
| s = "" | |
| for field in st._fields_: | |
| name = field[0] | |
| value = getattr(st, name) | |
| offset = start_offset + offsetof(st, name) | |
| s += f"field: {name}, " | |
| s += f"value: {value}, " | |
| s += f"offset: {hex(offset)}, " | |
| offset32 = offset / 4 | |
| offset32_int = int(offset32) | |
| if offset32_int == offset32: | |
| s += f"offset32: {hex(offset32_int)}, " | |
| s += "\n" | |
| print(s) | |
| def parse_data(f: BufferedReader): | |
| expected_sha1 = f.read(SHA_SIZE) | |
| data = f.read() | |
| assert len(data) % 0x1400 == 0 | |
| m = hashlib.sha1() | |
| m.update(data) | |
| sha1 = m.digest() | |
| assert expected_sha1 == sha1 | |
| return decrypt_data(data) | |
| def parse_config_header(data: bytearray): | |
| offset = 0 | |
| config_header = ConfigHeader.from_buffer(data, offset) | |
| if config_header.version > MAX_SUPPORTED_VERSION: | |
| raise ValueError(f"Invalid version {config_header.version}") | |
| print_fields_offsets(config_header, offset) | |
| return config_header | |
| def parse_params(config_header: ConfigHeader, data: bytearray): | |
| offset = sizeof(config_header) | |
| config_params_v1 = ConfigParamsV1.from_buffer(data, offset) | |
| print_fields_offsets(config_params_v1, offset) | |
| offset += sizeof(config_params_v1) | |
| if config_header.version < 2: | |
| return | |
| config_params_v2 = ConfigParamsV2.from_buffer(data, offset) | |
| print_fields_offsets(config_params_v2, offset) | |
| offset += sizeof(config_params_v2) | |
| if config_header.version < 3: | |
| return | |
| config_params_v3 = ConfigParamsV3.from_buffer(data, offset) | |
| print_fields_offsets(config_params_v3, offset) | |
| offset += sizeof(config_params_v3) | |
| if config_header.version < 4: | |
| return | |
| config_params_v4 = ConfigParamsV4.from_buffer(data, offset) | |
| print_fields_offsets(config_params_v4, offset) | |
| offset += sizeof(config_params_v4) | |
| end = sizeof(config_header) + config_header.size | |
| if offset != end: | |
| print("Leftover config header data:") | |
| print(data[offset:end]) | |
| print() | |
| def parse_prebak_effect( | |
| data: bytearray, | |
| offset: int, | |
| group: int, | |
| effect_id: int, | |
| effects: List[PrebakedEffect], | |
| ): | |
| effect = PrebakedEffect(group, effect_id) | |
| for _ in range(3): | |
| config_effect = ConfigEffect.from_buffer(data, offset) | |
| print_fields_offsets(config_effect, offset) | |
| offset += sizeof(config_effect) | |
| if config_effect.id - 1 != effect_id: | |
| raise ValueError(f"Invalid effect id: {config_effect.id}") | |
| if config_effect.effect_level > 3 or config_effect.effect_level < 1: | |
| raise ValueError(f"Invalid effect level: {config_effect.effect_level}") | |
| effect_data = data[offset : offset + config_effect.size] | |
| offset += config_effect.size | |
| effect.add_effect(config_effect.effect_level - 1, effect_data) | |
| effects.append(effect) | |
| return offset | |
| def parse_prebak_effects(data: bytearray, group: int, effects: List[PrebakedEffect]): | |
| offset = 0 | |
| config_effects = ConfigEffects.from_buffer(data, offset) | |
| print_fields_offsets(config_effects, offset) | |
| offset += sizeof(config_effects) | |
| for i in range(config_effects.size): | |
| offset = parse_prebak_effect(data, offset, group, i, effects) | |
| return offset | |
| def parse_more( | |
| data: bytearray, | |
| offset: int, | |
| ): | |
| config_more = ConfigMore.from_buffer(data, offset) | |
| print_fields_offsets(config_more, offset) | |
| offset += sizeof(config_more) | |
| # TODO: find what this data is, as it seems very random | |
| # src = data[offset : offset + config_more.size] | |
| offset += config_more.size | |
| return offset, config_more | |
| def parse_effect_arrays( | |
| data: bytearray, | |
| offset: int, | |
| ): | |
| config_effect_arrays = ConfigEffectArrays.from_buffer(data, offset) | |
| print_fields_offsets(config_effect_arrays, offset) | |
| offset += sizeof(config_effect_arrays) | |
| return offset, config_effect_arrays | |
| def parse_effects( | |
| config_header: ConfigHeader, | |
| data: bytearray, | |
| effects: List[PrebakedEffect], | |
| ): | |
| offset = sizeof(ConfigHeader) + config_header.size | |
| for _ in range(2): | |
| offset, _ = parse_more(data, offset) | |
| offset += parse_prebak_effects(data[offset:], 0, effects) | |
| offset += parse_prebak_effects(data[offset:], 1, effects) | |
| return offset | |
| def parse_effects_v5( | |
| config_header: ConfigHeader, | |
| data: bytearray, | |
| effects: List[PrebakedEffect], | |
| ): | |
| offset = sizeof(ConfigHeader) + config_header.size | |
| for _ in range(4): | |
| # Last two seem optional, but they parse fine even if they're | |
| # zeroed | |
| offset, _ = parse_more(data, offset) | |
| group = 0 | |
| for _ in range(2): | |
| offset, config_effect_arrays = parse_effect_arrays(data, offset) | |
| for _ in range(config_effect_arrays.length): | |
| offset += parse_prebak_effects(data[offset:], group, effects) | |
| group += 1 | |
| return offset | |
| def get_effect_level_str(effect_level): | |
| if effect_level == 2: | |
| effect_strength = "strong" | |
| elif effect_level == 1: | |
| effect_strength = "medium" | |
| else: | |
| effect_strength = "light" | |
| return effect_strength | |
| def get_effect_name(effect: ConfigEffect): | |
| return f"{effect.id - 1}" | |
| def get_effect_arr_name(effect_name: str, effect_level: int): | |
| effect_level_str = get_effect_level_str(effect_level) | |
| return f"effect_{effect_name}_{effect_level_str}" | |
| def get_effect_data_time_ms(effect_data: bytearray): | |
| return round(len(effect_data) * 1000 / 24000, 3) | |
| def convert_effect_data_int(effect_data: bytearray): | |
| return [unpack_int8_t(x) for x in effect_data] | |
| def convert_effect_data( | |
| effect_data: bytearray, | |
| ): | |
| effect_data_int = convert_effect_data_int(effect_data) | |
| # TODO: find out if zeros break the streaming | |
| # effect_data_int = [1 if x == 0 else x for x in effect_data_int] | |
| effect_data_strs = [str(x) for x in effect_data_int] | |
| # Pad left to align all of them | |
| effect_data_strs = [f"{x:>4}," for x in effect_data_strs] | |
| # Add one for space | |
| return effect_data_strs | |
| def write_prebak_effect( | |
| o: TextIO, | |
| name: str, | |
| effect_level: int, | |
| effect_data: bytearray, | |
| ): | |
| effect_data_strs = convert_effect_data(effect_data) | |
| # Add one for space | |
| len_one_data = len(effect_data_strs[0]) + 1 | |
| num_data_per_line = 80 // len_one_data | |
| effect_arr_name = get_effect_arr_name(name, effect_level) | |
| o.write(f"static const int8_t {effect_arr_name}[] = {{\n") | |
| for i, x in enumerate(effect_data_strs): | |
| o.write(x) | |
| if i != len(effect_data) - 1: | |
| if (i + 1) % num_data_per_line == 0: | |
| o.write("\n") | |
| else: | |
| o.write(" ") | |
| o.write("\n};\n") | |
| def write_prebak_effects_entry( | |
| o: TextIO, | |
| aosp_effect_id: EffectId, | |
| effect: Effect, | |
| effects_hz: int, | |
| effect_level: int, | |
| ): | |
| effect_id_int, _ = aosp_effect_id.int_str() | |
| effect_arr_name = get_effect_arr_name(effect.name, effect_level) | |
| effect_data = effect.effect_level_data[effect_level] | |
| o.write( | |
| f""" | |
| {{ | |
| .effect_id = {effect_id_int}, | |
| .length = {len(effect_data)}, | |
| .play_rate_hz = {effects_hz}, | |
| .data = {effect_arr_name}, | |
| }}, | |
| """.lstrip("\n") | |
| ) | |
| def write_prebak_effects_array( | |
| o: TextIO, | |
| aosp_effect_id: EffectId, | |
| effect: Effect, | |
| effects_hz: int, | |
| ): | |
| _, effect_id_str = aosp_effect_id.int_str() | |
| effect_levels = effect.effect_level_data.keys() | |
| sorted_effect_levels = sorted(effect_levels) | |
| o.write(f"static const struct effect_stream effects_{effect_id_str}[] = {{\n") | |
| for effect_level in sorted_effect_levels: | |
| write_prebak_effects_entry( | |
| o, | |
| aosp_effect_id, | |
| effect, | |
| effects_hz, | |
| effect_level, | |
| ) | |
| o.write("};\n\n") | |
| def parse_config(config_path: str, effects: List[PrebakedEffect]): | |
| with open(config_path, "rb") as i: | |
| data = parse_data(i) | |
| config_header = parse_config_header(data) | |
| parse_params(config_header, data) | |
| if config_header.version == 5: | |
| offset = parse_effects_v5(config_header, data, effects) | |
| else: | |
| offset = parse_effects(config_header, data, effects) | |
| for byte in data[offset:]: | |
| assert byte == 0xFF or byte == 0x00 | |
| def get_effect_by_id(effects: List[PrebakedEffect], i: Tuple[int, int]): | |
| for effect in effects: | |
| if i[0] == effect.group and i[1] == effect.effect_id: | |
| return effect | |
| raise ValueError(f"Failed to find effect for id {i}") | |
| def write_prebak_effects( | |
| o: TextIO, | |
| effect: Effect, | |
| target_effect_level: Optional[int], | |
| ): | |
| for effect_level, effect_data in effect.effect_level_data.items(): | |
| if target_effect_level != effect_level: | |
| continue | |
| write_prebak_effect(o, effect.name, effect_level, effect_data) | |
| o.write("\n") | |
| def write_effects( | |
| effects_path: str, | |
| effects: List[PrebakedEffect], | |
| effect_ids_map: effect_ids_map_type, | |
| effects_hz: int, | |
| target_effect_level: Optional[int] = None, | |
| ): | |
| with open(effects_path, "w", encoding="utf-8") as o: | |
| o.write( | |
| """ | |
| /* | |
| * SPDX-FileCopyrightText: 2025 The LineageOS Project | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| """.lstrip() | |
| ) | |
| sorted_unique_effect_ids = sorted(list(set(effect_ids_map.values()))) | |
| sorted_aosp_effect_ids = sorted(effect_ids_map.items(), key=lambda p: p[0]) | |
| for effect_id in sorted_unique_effect_ids: | |
| effect = get_effect_by_id(effects, effect_id) | |
| write_prebak_effects(o, effect, target_effect_level) | |
| if target_effect_level is not None: | |
| o.write("static const struct effect_stream effects[] = {\n") | |
| for aosp_effect_id, effect_id in sorted_aosp_effect_ids: | |
| effect = get_effect_by_id(effects, effect_id) | |
| write_prebak_effects_entry( | |
| o, | |
| aosp_effect_id, | |
| effect, | |
| effects_hz, | |
| effect_level=target_effect_level, | |
| ) | |
| o.write("};\n") | |
| return | |
| for aosp_effect_id, effect_id in effect_ids_map.items(): | |
| effect = get_effect_by_id(effects, effect_id) | |
| write_prebak_effects_array(o, aosp_effect_id, effect, effects_hz) | |
| o.write("static const struct effect_stream *effects[] = {\n") | |
| for aosp_effect_id, _ in sorted_aosp_effect_ids: | |
| effect_id_int, effect_id_str = aosp_effect_id.int_str() | |
| o.write(f" [{effect_id_int}] = effects_{effect_id_str},\n") | |
| o.write("};\n") | |
| def replace_in_aosp_effect_ids( | |
| effect_ids_map: effect_ids_map_type, | |
| removed: PrebakedEffect, | |
| replacement: PrebakedEffect, | |
| ): | |
| for aosp_effect_id, effect_ids in effect_ids_map.items(): | |
| if (removed.group, removed.effect_id) == effect_ids: | |
| effect_ids_map[aosp_effect_id] = (replacement.group, replacement.effect_id) | |
| def remove_duplicate_effects( | |
| effects: List[PrebakedEffect], | |
| effect_ids_map: effect_ids_map_type, | |
| ): | |
| # Remove duplicate effects | |
| removed_effects: List[PrebakedEffect] = [] | |
| for first, second in combinations(effects, 2): | |
| all_effect_same = True | |
| for level, first_data in first.effect_level_data.items(): | |
| second_data = second.effect_level_data[level] | |
| if first_data != second_data: | |
| all_effect_same = False | |
| if all_effect_same and second not in removed_effects: | |
| print(f"Remove effect {second.name}, duplicate of {first.name}") | |
| replace_in_aosp_effect_ids(effect_ids_map, second, first) | |
| removed_effects.append(second) | |
| for effect in removed_effects: | |
| effects.remove(effect) | |
| def run(): | |
| if len(sys.argv) < 2: | |
| raise ValueError(f"usage: {sys.argv[0]} <aac_richtap.config> [effects.cpp]") | |
| config_path = sys.argv[1] | |
| effects_path = None | |
| if len(sys.argv) == 3: | |
| effects_path = sys.argv[2] | |
| # Keys are AOSP Effect IDs | |
| # Values found by grepping logcat for `AacRichTapConvert: effect_id:` | |
| # and pressing buttons in VibeTest | |
| # By checking the data lengths, have found that the IDs are offset by | |
| # 0x3001 | |
| # Leftover: (1, 0), (1, 1) | |
| effect_level = 2 | |
| effect_ids_map = { | |
| # original: (1, 9), # with 0x3001: 12298 | |
| EffectId.CLICK: (1, 8), | |
| EffectId.DOUBLE_CLICK: (0, 1), | |
| # deduped: (1, 1), # original: (1, 2), # with 0x3001: 12291 | |
| EffectId.TICK: (0, 0), | |
| # with 0x3001: 12295 | |
| EffectId.THUD: (1, 6), | |
| # with 0x3001: 12296 | |
| EffectId.POP: (1, 7), | |
| # original: (1, 6), # with 0x3001: 12295 | |
| EffectId.HEAVY_CLICK: (1, 5), | |
| # no original | |
| EffectId.TEXTURE_TICK: (0, 2), | |
| # Use ringtones to test effects | |
| EffectId.RINGTONE_1: (0, 0), | |
| EffectId.RINGTONE_2: (0, 1), | |
| EffectId.RINGTONE_3: (0, 2), | |
| EffectId.RINGTONE_4: (1, 0), | |
| EffectId.RINGTONE_5: (1, 1), | |
| EffectId.RINGTONE_6: (1, 5), | |
| EffectId.RINGTONE_7: (1, 6), | |
| EffectId.RINGTONE_8: (1, 7), | |
| EffectId.RINGTONE_9: (1, 8), | |
| } | |
| effects_hz = 24000 | |
| effects: List[PrebakedEffect] = [] | |
| parse_config(config_path, effects) | |
| max_len = 0 | |
| for effect in effects: | |
| for effect_data in effect.effect_level_data.values(): | |
| max_len = max(max_len, len(effect_data)) | |
| remove_duplicate_effects(effects, effect_ids_map) | |
| # import matplotlib.pyplot as plt | |
| # fig, axs = plt.subplots(len(effects), 3, figsize=(15, 15)) | |
| # for effect_index, effect in enumerate(effects): | |
| # for effect_level, effect_data in effect.effect_level_data.items(): | |
| # converted_effect_data = convert_effect_data_int(effect_data) | |
| # time_ms = get_effect_data_time_ms(effect_data) | |
| # label = f"[{effect_index}] = {effect.name}@{effect_level} {time_ms}ms {len(effect_data)}samples" | |
| # ax = axs[effect_index, effect_level] | |
| # ax.set_ylim(-128, 127) | |
| # ax.set_xlim(0, max_len) | |
| # ax.plot(converted_effect_data) | |
| # ax.set_title(label) | |
| # plt.tight_layout() | |
| # plt.show() | |
| if effects_path is not None: | |
| write_effects( | |
| effects_path, | |
| effects, | |
| effect_ids_map, | |
| effects_hz, | |
| target_effect_level=effect_level, | |
| ) | |
| if __name__ == "__main__": | |
| run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment