From 666298f037b696d5ffc57d01f89be1feed280363 Mon Sep 17 00:00:00 2001 From: kxn Date: Thu, 10 Sep 2026 13:59:42 +0800 Subject: [PATCH] feat: separate video quality preference from bandwidth limit --- app/platforms/common/runtime.c | 14 +++++- app/platforms/common/ui_renderer.c | 14 ++++-- app/resources/i18n/en.json | 1 + app/resources/i18n/zh-CN.json | 1 + app/src/services/auth_store.c | 56 +++++++++++++++++++---- app/src/services/auth_store.h | 5 ++ app/src/ui/ui_events.c | 1 + app/src/ui/ui_layout.c | 18 ++++++-- app/src/ui/ui_model.c | 21 ++++++++- app/src/ui/ui_model.h | 4 ++ app/tests/test_native.c | 2 +- app/tests/test_ui.c | 73 +++++++++++++++++++++++++++--- app/tests/test_ui_audio.c | 2 +- docs/STEAMLINK_PROTOCOL_RE.md | 51 +++++++++++++++++++++ docs/UI_UX_DESIGN.md | 4 +- third_party/ihslib | 2 +- 16 files changed, 237 insertions(+), 32 deletions(-) diff --git a/app/platforms/common/runtime.c b/app/platforms/common/runtime.c index e2815e1..5d2b0ca 100644 --- a/app/platforms/common/runtime.c +++ b/app/platforms/common/runtime.c @@ -328,8 +328,18 @@ static void configuring(IHS_Session *s, IHS_SessionConfig *c, void *ctx) { c->maxWidth = 1280; c->maxHeight = 720; c->maxFps = 60; - const uint32_t rates[] = {6000, 4000, 10000}; - c->maxBitrateKbps = rates[r->active.quality <= 2 ? r->active.quality : 0]; + switch (r->active.quality) { + case 1: + c->quality = IHS_StreamQualityFast; + break; + case 2: + c->quality = IHS_StreamQualityBeautiful; + break; + default: + c->quality = IHS_StreamQualityBalanced; + break; + } + c->maxBitrateKbps = sl_bitrate_valid(r->active.bitrate_kbps) ? r->active.bitrate_kbps : 6000; } static void connected(IHS_Session *s, void *ctx) { (void)s; diff --git a/app/platforms/common/ui_renderer.c b/app/platforms/common/ui_renderer.c index d0f3fa9..2584f1a 100644 --- a/app/platforms/common/ui_renderer.c +++ b/app/platforms/common/ui_renderer.c @@ -484,6 +484,8 @@ static sl_gfx_color action_color(sl_action a) { case SL_BACK: return green; case SL_OPEN_SETTINGS: + case SL_OPEN_BANDWIDTH: + case SL_SET_BANDWIDTH: case SL_OPEN_QUALITY: case SL_SET_QUALITY: return violet; @@ -503,7 +505,7 @@ static sl_gfx_color action_color(sl_action a) { static void action_icon(sl_gfx *r, sl_action a, int x, int y, sl_gfx_color c) { if (a == SL_BACK || a == SL_START || a == SL_RECENT) { play_icon(r, x + 7, y + 14, 19, c); - } else if (a == SL_OPEN_SETTINGS || a == SL_SET_QUALITY) { + } else if (a == SL_OPEN_SETTINGS || a == SL_OPEN_BANDWIDTH || a == SL_SET_QUALITY) { for (int i = 0; i < 3; ++i) { int yy = y + 6 + i * 8, xx = x + (i == 1 ? 17 : 9); line(r, x + 3, yy, x + 27, yy, c); @@ -868,14 +870,16 @@ static void draw_scene(sl_ui_renderer *r, const sl_ui_model *m, const sl_debug_s } else if (menu_row) { if (focused) rounded(r->renderer, box, 8, fg); - if (c->action == SL_SET_QUALITY || c->action == SL_SET_LANGUAGE) { + if (c->action == SL_SET_QUALITY || c->action == SL_SET_BANDWIDTH || + c->action == SL_SET_LANGUAGE) { int x = box.x + 23, y = box.y + (box.h - 26) / 2; sl_gfx_color surface = focused ? fg : panel; sl_gfx_color ring = focused ? bg : muted; rounded(r->renderer, (sl_gfx_rect){x, y, 26, 26}, 13, ring); rounded(r->renderer, (sl_gfx_rect){x + 2, y + 2, 22, 22}, 11, surface); - if (c->arg == (c->action == SL_SET_LANGUAGE ? (int)sl_i18n_language() - : (int)m->store.quality)) + if (c->arg == (c->action == SL_SET_LANGUAGE ? (int)sl_i18n_language() + : c->action == SL_SET_BANDWIDTH ? (int)m->store.bitrate_kbps + : (int)m->store.quality)) rounded(r->renderer, (sl_gfx_rect){x + 6, y + 6, 14, 14}, 7, focused ? bg : green); } else { @@ -954,7 +958,7 @@ static void draw_scene(sl_ui_renderer *r, const sl_ui_model *m, const sl_debug_s rounded(r->renderer, (sl_gfx_rect){tx + (m->store.sound ? 25 : 3), ty + 3, 22, 22}, 11, bg); } else if (c->action != SL_BACK && c->action != SL_SET_QUALITY && - c->action != SL_SET_LANGUAGE) + c->action != SL_SET_BANDWIDTH && c->action != SL_SET_LANGUAGE) chevron(r->renderer, box.x + box.w - 35, box.y + box.h / 2, focused ? bg : muted); } sl_gfx_clip(r->renderer, card ? &clip : &box); diff --git a/app/resources/i18n/en.json b/app/resources/i18n/en.json index e56afc2..be18764 100644 --- a/app/resources/i18n/en.json +++ b/app/resources/i18n/en.json @@ -1,4 +1,5 @@ { + "BANDWIDTH": "Bandwidth limit", "LAST_ADDRESS": "Last IP: ", "PAIRED": "Paired", "UNPAIRED": "Not paired", diff --git a/app/resources/i18n/zh-CN.json b/app/resources/i18n/zh-CN.json index d710b45..28a029f 100644 --- a/app/resources/i18n/zh-CN.json +++ b/app/resources/i18n/zh-CN.json @@ -1,4 +1,5 @@ { + "BANDWIDTH": "带宽限制", "LAST_ADDRESS": "上次地址 ", "PAIRED": "已配对", "UNPAIRED": "未配对", diff --git a/app/src/services/auth_store.c b/app/src/services/auth_store.c index a9f10e4..d79a5a2 100644 --- a/app/src/services/auth_store.c +++ b/app/src/services/auth_store.c @@ -18,6 +18,20 @@ typedef struct __attribute__((packed)) legacy_auth { uint16_t port; uint8_t reserved[30]; } legacy_auth; +/* Frozen v2 layout: do not use the growing runtime structure to read old identities. */ +typedef struct auth_store_v2 { + uint64_t device_id; + uint8_t secret[32]; + char device_name[64]; + sl_host_registry registry; + uint32_t quality; + bool sound; +} auth_store_v2; +typedef struct disk_store_v2 { + char magic[8]; + uint32_t version, size, checksum; + auth_store_v2 data; +} disk_store_v2; typedef struct disk_store { char magic[8]; uint32_t version, size, checksum; @@ -66,10 +80,12 @@ bool sl_auth_save(const sl_auth_store *s, const char *dir) { snprintf(tmp, sizeof(tmp), "%s/profile.tmp", dir); snprintf(backup, sizeof(backup), "%s/profile.bak", dir); disk_store d = {0}; - memcpy(d.magic, "NSLUI02", 8); - d.version = 2; + memcpy(d.magic, "NSLUI03", 8); + d.version = 3; d.size = sizeof(d); d.data = *s; + if (!sl_bitrate_valid(d.data.bitrate_kbps)) + d.data.bitrate_kbps = 6000; for (int i = 0; i < d.data.registry.count; ++i) { d.data.registry.hosts[i].observed = false; d.data.registry.hosts[i].last_seen = 0; @@ -97,6 +113,7 @@ int sl_auth_load(sl_auth_store *s, const char *dir) { memset(s, 0, sizeof(*s)); sl_host_registry_init(&s->registry); s->sound = true; + s->bitrate_kbps = 6000; char path[512]; snprintf(path, sizeof(path), "%s/profile.bin", dir); FILE *f = fopen(path, "rb"); @@ -108,13 +125,36 @@ int sl_auth_load(sl_auth_store *s, const char *dir) { recovered = f != NULL; } if (f) { - disk_store d; - bool ok = fread(&d, 1, sizeof(d), f) == sizeof(d) && fgetc(f) == EOF; + disk_store d = {0}; + char magic[8]; + bool ok = fread(magic, 1, sizeof(magic), f) == sizeof(magic); + rewind(f); + if (ok && !memcmp(magic, "NSLUI02", 8)) { + disk_store_v2 old; + ok = fread(&old, 1, sizeof(old), f) == sizeof(old) && fgetc(f) == EOF; + ok = ok && old.version == 2 && old.size == sizeof(old) && + old.checksum == checksum(&old.data, sizeof(old.data)) && old.data.quality <= 2; + if (ok) { + d.data.device_id = old.data.device_id; + memcpy(d.data.secret, old.data.secret, sizeof(d.data.secret)); + memcpy(d.data.device_name, old.data.device_name, sizeof(d.data.device_name)); + d.data.registry = old.data.registry; + d.data.quality = old.data.quality; + d.data.sound = old.data.sound; + const uint32_t old_rates[] = {6000, 4000, 10000}; + d.data.bitrate_kbps = old_rates[old.data.quality]; + } + } else if (ok && !memcmp(magic, "NSLUI03", 8)) { + ok = fread(&d, 1, sizeof(d), f) == sizeof(d) && fgetc(f) == EOF; + ok = ok && d.version == 3 && d.size == sizeof(d) && + d.checksum == checksum(&d.data, sizeof(d.data)); + } else { + ok = false; + } fclose(f); - if (!ok || memcmp(d.magic, "NSLUI02", 8) || d.version != 2 || d.size != sizeof(d) || - d.checksum != checksum(&d.data, sizeof(d.data)) || !d.data.device_id || - d.data.registry.count < 0 || d.data.registry.count > SL_HOST_LIMIT || - d.data.quality > 2) + if (!ok || !d.data.device_id || d.data.registry.count < 0 || + d.data.registry.count > SL_HOST_LIMIT || d.data.quality > 2 || + !sl_bitrate_valid(d.data.bitrate_kbps)) return -2; if (recovered && rename(backup, path)) { store_error("recover"); diff --git a/app/src/services/auth_store.h b/app/src/services/auth_store.h index 017d1cb..9287e54 100644 --- a/app/src/services/auth_store.h +++ b/app/src/services/auth_store.h @@ -8,7 +8,12 @@ typedef struct sl_auth_store { sl_host_registry registry; uint32_t quality; bool sound; + uint32_t bitrate_kbps; } sl_auth_store; +/* Explicit bandwidth choices; independent of the quality preference. */ +static inline bool sl_bitrate_valid(uint32_t kbps) { + return kbps == 4000 || kbps == 6000 || kbps == 10000 || kbps == 20000; +} /* 0 loaded, 1 created, negative: corrupt/unreadable; never replaces corrupt identity. */ int sl_auth_load(sl_auth_store *store, const char *directory); bool sl_auth_save(const sl_auth_store *store, const char *directory); diff --git a/app/src/ui/ui_events.c b/app/src/ui/ui_events.c index 4124f13..ffef7f6 100644 --- a/app/src/ui/ui_events.c +++ b/app/src/ui/ui_events.c @@ -51,6 +51,7 @@ void sl_ui_runtime_event(sl_ui_model *m, const sl_runtime_event *event) { m->command.generation = m->generation; m->command.type = SL_CMD_STREAM; m->command.quality = m->store.quality; + m->command.bitrate_kbps = m->store.bitrate_kbps; } else sl_ui_error(m, sl_tr(SL_T_HOST_REMOVED)); break; diff --git a/app/src/ui/ui_layout.c b/app/src/ui/ui_layout.c index eee1cdb..11886a1 100644 --- a/app/src/ui/ui_layout.c +++ b/app/src/ui/ui_layout.c @@ -128,10 +128,11 @@ void sl_ui_layout(sl_ui_model *m) { case SL_SETTINGS: strcpy(l->title, sl_tr(SL_T_SETTINGS)); row(l, 0, sl_tr(SL_T_QUALITY), SL_OPEN_QUALITY, 0); - row(l, 1, m->store.sound ? sl_tr(SL_T_SOUND_ON) : sl_tr(SL_T_SOUND_OFF), SL_SOUND, 0); - row(l, 2, sl_tr(SL_T_LANGUAGE), SL_OPEN_LANGUAGE, 0); + row(l, 1, sl_tr(SL_T_BANDWIDTH), SL_OPEN_BANDWIDTH, 0); + row(l, 2, m->store.sound ? sl_tr(SL_T_SOUND_ON) : sl_tr(SL_T_SOUND_OFF), SL_SOUND, 0); + row(l, 3, sl_tr(SL_T_LANGUAGE), SL_OPEN_LANGUAGE, 0); if (!m->streaming) - row(l, 3, sl_tr(SL_T_MANUAL), SL_OPEN_MANUAL, 0); + row(l, 4, sl_tr(SL_T_MANUAL), SL_OPEN_MANUAL, 0); break; case SL_LANGUAGE: strcpy(l->title, sl_tr(SL_T_LANGUAGE)); @@ -139,6 +140,15 @@ void sl_ui_layout(sl_ui_model *m) { row(l, 1, sl_tr(SL_T_CHINESE), SL_SET_LANGUAGE, SL_LANG_ZH_CN); row(l, 2, sl_tr(SL_T_ENGLISH), SL_SET_LANGUAGE, SL_LANG_EN); break; + case SL_BANDWIDTH: + strcpy(l->title, sl_tr(SL_T_BANDWIDTH)); + row(l, 0, "4 Mbps", SL_SET_BANDWIDTH, 4000); + row(l, 1, "6 Mbps", SL_SET_BANDWIDTH, 6000); + row(l, 2, "10 Mbps", SL_SET_BANDWIDTH, 10000); + row(l, 3, "20 Mbps", SL_SET_BANDWIDTH, 20000); + if (m->streaming) + text(l, 320, 548, 26, sl_tr(SL_T_NEXT_CONNECTION)); + break; case SL_QUALITY: strcpy(l->title, sl_tr(SL_T_QUALITY)); row(l, 0, sl_tr(SL_T_BALANCED), SL_SET_QUALITY, 0); @@ -227,7 +237,7 @@ void sl_ui_layout(sl_ui_model *m) { } if (l->dialog) { l->drawer = m->page == SL_MENU || m->page == SL_OPTIONS || m->page == SL_SETTINGS || - m->page == SL_QUALITY || m->page == SL_LANGUAGE; + m->page == SL_QUALITY || m->page == SL_BANDWIDTH || m->page == SL_LANGUAGE; l->compact = m->page == SL_END_GAME || m->page == SL_DISCONNECT || m->page == SL_FORGET || m->page == SL_EXIT || m->page == SL_ERROR || m->page == SL_STOPPING || m->page == SL_INSTALLING; diff --git a/app/src/ui/ui_model.c b/app/src/ui/ui_model.c index 34307f0..c9030eb 100644 --- a/app/src/ui/ui_model.c +++ b/app/src/ui/ui_model.c @@ -109,6 +109,8 @@ void sl_ui_release_games(sl_ui_model *m, float velocity) { void sl_ui_init(sl_ui_model *m, const sl_auth_store *s) { memset(m, 0, sizeof(*m)); m->store = *s; + if (!sl_bitrate_valid(m->store.bitrate_kbps)) + m->store.bitrate_kbps = 6000; m->page = SL_HOME; m->network_ok = true; sl_ui_layout(m); @@ -172,6 +174,7 @@ static void emit(sl_ui_model *m, sl_command_type type) { m->command.type = type; m->command.generation = m->generation; m->command.quality = m->store.quality; + m->command.bitrate_kbps = m->store.bitrate_kbps; m->command.language = sl_i18n_language(); } static void start(sl_ui_model *m, int game) { @@ -329,6 +332,15 @@ static void action(sl_ui_model *m, sl_action a, int arg) { emit(m, SL_CMD_SAVE); } break; + case SL_OPEN_BANDWIDTH: { + push(m, SL_BANDWIDTH); + const uint32_t rates[] = {4000, 6000, 10000, 20000}; + m->focus = 101; + for (int i = 0; i < 4; ++i) + if (m->store.bitrate_kbps == rates[i]) + m->focus = 100 + i; + break; + } case SL_OPEN_QUALITY: push(m, SL_QUALITY); m->focus = 100 + (m->store.quality <= 2 ? (int)m->store.quality : 0); @@ -431,8 +443,15 @@ static void action(sl_ui_model *m, sl_action a, int arg) { emit(m, SL_CMD_MANUAL); } break; + case SL_SET_BANDWIDTH: + if (m->page == SL_BANDWIDTH && sl_bitrate_valid((uint32_t)arg)) { + m->store.bitrate_kbps = (uint32_t)arg; + emit(m, SL_CMD_SAVE); + back(m); + } + break; case SL_SET_QUALITY: - if (arg >= 0 && arg <= 2) { + if (m->page == SL_QUALITY && arg >= 0 && arg <= 2) { m->store.quality = (uint32_t)arg; emit(m, SL_CMD_SAVE); back(m); diff --git a/app/src/ui/ui_model.h b/app/src/ui/ui_model.h index 88a20f4..1789511 100644 --- a/app/src/ui/ui_model.h +++ b/app/src/ui/ui_model.h @@ -18,6 +18,7 @@ typedef enum sl_page { SL_SETTINGS, SL_MANUAL, SL_QUALITY, + SL_BANDWIDTH, SL_LANGUAGE, SL_FORGET, SL_END_GAME, @@ -48,6 +49,7 @@ typedef enum sl_action { SL_CONFIRM_SHORTCUT, SL_OPEN_MANUAL, SL_OPEN_QUALITY, + SL_OPEN_BANDWIDTH, SL_OPEN_LANGUAGE, SL_SET_LANGUAGE, SL_OPEN_FORGET, @@ -61,6 +63,7 @@ typedef enum sl_action { SL_ERASE, SL_SUBMIT, SL_SET_QUALITY, + SL_SET_BANDWIDTH, SL_SOUND, SL_RETRY } sl_action; @@ -82,6 +85,7 @@ typedef struct sl_command { sl_host host; uint64_t game_id; uint32_t quality; + uint32_t bitrate_kbps; sl_language language; char text[64]; } sl_command; diff --git a/app/tests/test_native.c b/app/tests/test_native.c index 6f8dae3..987a748 100644 --- a/app/tests/test_native.c +++ b/app/tests/test_native.c @@ -330,7 +330,7 @@ int main(int argc, char **argv) { ui.page = p; ui.depth = 0; ui.leaving = false; - ui.streaming = p == SL_STREAM || p == SL_MENU; + ui.streaming = p == SL_STREAM || p == SL_MENU || p == SL_BANDWIDTH; ui.ending_game = p == SL_STOPPING; ui.debug = p == SL_STREAM; ui.stream_started_at = ui.now; diff --git a/app/tests/test_ui.c b/app/tests/test_ui.c index 4a6e930..9d2b689 100644 --- a/app/tests/test_ui.c +++ b/app/tests/test_ui.c @@ -327,8 +327,8 @@ static void actionable_settings(void) { sl_ui_action(&m, SL_OPEN_OPTIONS, 0); assert(m.layout.count == 3); /* Settings, HOME shortcut and back. */ sl_ui_action(&m, SL_OPEN_SETTINGS, 0); - assert(m.layout.count == 5); /* Quality, sound, language, manual address, back. */ - assert(m.layout.controls[3].action == SL_OPEN_MANUAL); + assert(m.layout.count == 6); /* Quality, bandwidth, sound, language, manual address, back. */ + assert(m.layout.controls[4].action == SL_OPEN_MANUAL); sl_ui_action(&m, SL_OPEN_MANUAL, 0); assert(m.page == SL_MANUAL); sl_ui_action(&m, SL_BACK, 0); @@ -336,6 +336,12 @@ static void actionable_settings(void) { sl_ui_action(&m, SL_SOUND, 0); sl_command command; assert(!m.store.sound && sl_ui_take_command(&m, &command) && command.type == SL_CMD_SAVE); + sl_ui_action(&m, SL_OPEN_BANDWIDTH, 0); + sl_ui_action(&m, SL_SET_BANDWIDTH, 20000); + assert(m.store.bitrate_kbps == 20000 && m.store.quality == 0); + assert(sl_ui_take_command(&m, &command) && command.type == SL_CMD_SAVE); + assert(command.bitrate_kbps == 20000 && command.quality == 0); + sl_ui_tick(&m, m.now + 160); sl_ui_action(&m, SL_OPEN_QUALITY, 0); assert(!strcmp(m.layout.controls[0].label, "均衡")); assert(!strcmp(m.layout.controls[1].label, "流畅")); @@ -344,10 +350,11 @@ static void actionable_settings(void) { assert(m.store.quality == 0); /* Focus movement does not change the radio selection. */ sl_ui_action(&m, SL_SET_QUALITY, 2); assert(m.store.quality == 2 && sl_ui_take_command(&m, &command) && command.type == SL_CMD_SAVE); + assert(m.store.bitrate_kbps == 20000 && command.bitrate_kbps == 20000); sl_ui_connected(&m); sl_ui_action(&m, SL_OPEN_MENU, 0); sl_ui_action(&m, SL_OPEN_SETTINGS, 0); - assert(m.layout.count == 4); /* Sound, quality, language and back. */ + assert(m.layout.count == 5); /* Sound, quality, bandwidth, language and back. */ for (int i = 0; i < m.layout.count; ++i) assert(m.layout.controls[i].action != SL_OPEN_MANUAL); sl_ui_action(&m, SL_OPEN_MANUAL, 0); @@ -406,7 +413,58 @@ static void end_game_failure(void) { sl_ui_runtime_event(&m, &stopped); /* old completion must not end a new operation */ assert(m.page == SL_STOPPING && !m.ending_game); } + +static void profile_v2_migration(void) { + struct old_store { + uint64_t device_id; + uint8_t secret[32]; + char device_name[64]; + sl_host_registry registry; + uint32_t quality; + bool sound; + }; + struct old_disk { + char magic[8]; + uint32_t version, size, checksum; + struct old_store data; + } old = {0}; + char dir[] = "/tmp/nsl-profile-v2-XXXXXX", path[512]; + assert(mkdtemp(dir)); + snprintf(path, sizeof(path), "%s/profile.bin", dir); + memcpy(old.magic, "NSLUI02", 8); + old.version = 2; + old.size = sizeof(old); + old.data.device_id = 12345; + memset(old.data.secret, 0x5a, sizeof(old.data.secret)); + strcpy(old.data.device_name, "paired device"); + old.data.registry.count = 1; + old.data.registry.hosts[0].client_id = 98765; + old.data.registry.hosts[0].paired = true; + const uint32_t rates[] = {6000, 4000, 10000}; + for (unsigned q = 0; q < 3; ++q) { + old.data.quality = q; + old.checksum = 2166136261u; + const uint8_t *p = (const void *)&old.data; + for (size_t i = 0; i < sizeof(old.data); ++i) + old.checksum = (old.checksum ^ p[i]) * 16777619u; + FILE *f = fopen(path, "wb"); + assert(f && fwrite(&old, 1, sizeof(old), f) == sizeof(old)); + assert(fclose(f) == 0); + sl_auth_store s, reload; + assert(sl_auth_load(&s, dir) == 0); + assert(s.device_id == 12345 && !memcmp(s.secret, old.data.secret, 32)); + assert(!strcmp(s.device_name, "paired device") && !s.sound); + assert(s.registry.hosts[0].paired && s.registry.hosts[0].client_id == 98765); + assert(s.quality == q && s.bitrate_kbps == rates[q]); + assert(sl_auth_save(&s, dir) && sl_auth_load(&reload, dir) == 0); + assert(reload.quality == q && reload.bitrate_kbps == rates[q]); + assert(reload.device_id == s.device_id && reload.registry.hosts[0].paired); + } + unlink(path); + rmdir(dir); +} int main(void) { + profile_v2_migration(); end_game_failure(); carousel(); session_end_events(); @@ -683,12 +741,13 @@ int main(void) { assert(mkdtemp(dir)); sl_auth_store s, t; assert(sl_auth_load(&s, dir) == 1); - assert(s.device_id); + assert(s.device_id && s.bitrate_kbps == 6000); assert(sl_auth_load(&t, dir) == 0); assert(s.device_id == t.device_id && !memcmp(s.secret, t.secret, 32)); s.quality = 2; + s.bitrate_kbps = 20000; assert(sl_auth_save(&s, dir)); - assert(sl_auth_load(&t, dir) == 0 && t.quality == 2); + assert(sl_auth_load(&t, dir) == 0 && t.quality == 2 && t.bitrate_kbps == 20000); no_replace = true; s.quality = 1; assert(sl_auth_save(&s, dir)); @@ -704,7 +763,7 @@ int main(void) { snprintf(primary, sizeof(primary), "%s/profile.bin", dir); snprintf(backup, sizeof(backup), "%s/profile.bak", dir); assert(rename(primary, backup) == 0); /* interrupted before publication */ - assert(sl_auth_load(&t, dir) == 0 && t.quality == 2); + assert(sl_auth_load(&t, dir) == 0 && t.quality == 2 && t.bitrate_kbps == 20000); assert(access(primary, F_OK) == 0); /* A failed temporary write must preserve the previous valid profile. */ char tmp[512]; @@ -712,7 +771,7 @@ int main(void) { assert(mkdir(tmp, 0700) == 0); s.quality = 1; assert(!sl_auth_save(&s, dir)); - assert(sl_auth_load(&t, dir) == 0 && t.quality == 2); + assert(sl_auth_load(&t, dir) == 0 && t.quality == 2 && t.bitrate_kbps == 20000); assert(rmdir(tmp) == 0); char path[512]; snprintf(path, sizeof(path), "%s/profile.bin", dir); diff --git a/app/tests/test_ui_audio.c b/app/tests/test_ui_audio.c index 2afb042..d2afd96 100644 --- a/app/tests/test_ui_audio.c +++ b/app/tests/test_ui_audio.c @@ -17,7 +17,7 @@ static void feedback_model(void) { sl_ui_action(&m, SL_DOWN, 0); assert(m.cue_serial == 3); /* repeat is rate limited */ m.now += 100; - sl_ui_activate(&m, 101); /* touch uses the same action as the controller */ + sl_ui_activate(&m, 102); /* touch uses the same action as the controller */ assert(!m.store.sound && m.cue == SL_CUE_TOGGLE); sl_ui_action(&m, SL_BACK, 0); assert(m.cue == SL_CUE_BACK); diff --git a/docs/STEAMLINK_PROTOCOL_RE.md b/docs/STEAMLINK_PROTOCOL_RE.md index 04694d9..5b636b5 100644 --- a/docs/STEAMLINK_PROTOCOL_RE.md +++ b/docs/STEAMLINK_PROTOCOL_RE.md @@ -1418,3 +1418,54 @@ Conclusion:在已核对的消息和官方调用路径中,没有证明可用 host-stop,避免丢失清理期间到达的完成事实。这个检查只覆盖客户端实际收到的证据。 结束失败保留操作类型;会话已销毁时不显示连接“重试”,也不能由迟到/直接重试动作启动游戏。 返回首页后用户仍可主动重新启动。成功结束和取消都使该操作的迟到 UI 事件失效。 + +## 23. 画质偏好与带宽配置(Android 1.3.32) + +### 23.1 官方预设的证据 + +材料:同 §1 的官方 Android 1.3.32 ARM64 split 中 +`libshell_arm64-v8a.so`,SHA-256 +`15b2d79881eef0c47ed2f96c17d1d60a5f42d3285abcf9f512cfffdac5b238a2`。 +用 `aarch64-linux-gnu-objdump -d -C` 按下列符号/地址复核。 + +【证据】`CreateStreamingConfigFastPreset` (`0xc85b74`) 清空配置后设置 +`quality=1`、HEVC 和 unreliable FEC,再调用保留部分用户设置的辅助函数 +(`0xc85868`)。该函数没有写入固定码率或固定帧率。 +`CreateStreamingConfigPreset` (`0xc8562c`) 的 preset=1 分支清空配置后调用 +同一辅助函数;preset=2/3 分支设置 `quality=3`、HEVC、unreliable FEC、 +`desired_bitrate_kbps=0`,并分别写入 1920×1080 / 3840×2160 的视频限制。 +尺寸常量位于 `.rodata` 的 `0x4cdd70` / `0x4cdc20`。 + +字段定位交叉核对:配置对象 `+148` 是 quality;`OnBitrateChanged` +(`0xc909d8`) 写配置 `+152`;`OnHEVCChanged` (`0xc90ffc`) 写 `+105`; +`OnUnreliableFECChanged` (`0xc911a8`) 写 `+113`。 +`CPanelStreaming::OnPresetClicked` (`0xc9182c`) 直接调用上述 preset 工厂并保存设置。 +这里的 **EStreamingConfigPreset 与协议 EStreamQualityPreference 是不同枚举**, +不能把 preset=1/2/3 当作 Fast/Balanced/Beautiful 的映射。 +辅助函数保留硬件解码、音频、输入等部分设置;因此上述写入也不是所有设备最终配置的完整清单。 + +【结论】没有证据支持本项目旧有的“流畅=4M、均衡=6M、清晰=10M”是官方 profile。 +协议本身明确分离画质偏好(字段 1)与带宽请求(字段 6),也分离分辨率、帧率、 +编解码器能力。官方工厂包含超出当前 Switch 接线范围的设置,不整套移植。 +此前“尚未定位官方 preset 生成代码”的判断由这里的 shell 库证据补充; +仅检查流媒体核心 `libmain.so` 不足以判断 UI preset 行为。 + +【待验证】Host 对各画质偏好的具体编码器参数选择及实际画质/延迟收益; +不得根据枚举名宣称 Fast 会发送更高帧率,或 Beautiful 必然增加输入延迟。 + +### 23.2 本项目配置契约 + +- UI 均衡/流畅/清晰显式映射到协议 Balanced=2 / Fast=1 / Beautiful=3。 + IHSlib 提供独立公开枚举;零值及非法输入回落 Balanced,不暴露 protobuf 类型。 +- 带宽限制独立选择 4 / 6 / 10 / 20 Mbps,新安装默认 6 Mbps。 + 这些是本项目的手动带宽选项,**不是冒称官方的码率预设**。 +- 所有画质共用现有 H.264、1280×720、60 fps 请求;不随画质开启 HEVC、 + unreliable FEC 或修改渲染/解码时序。本变更不实现自动带宽或运行时 message 99。 +- 开始连接时将画质、码率复制进命令快照;会话使用这份快照。 + 串流时保存设置只影响下次连接,不并发修改当前协商配置。 +- profile v3 存储独立码率。读取 v2 时保留设备身份、密钥、配对主机和偏好, + 将旧偏好对应的 6/4/10 Mbps 迁移为独立码率;后续保存采用 v3。 + 文件头、长度、版本、校验和或范围不合法仍报损坏,不重建身份。 + 迁移保留旧码率,但旧 Fast/Beautiful 从此真正发送相应偏好,行为并非完全不变。 +- 协议测试解密实际 NegotiationSetConfig 并反序列化,交叉检查各画质与独立 + 码率、分辨率、帧率、编解码器;存储测试覆盖三种旧偏好和配对身份迁移。 diff --git a/docs/UI_UX_DESIGN.md b/docs/UI_UX_DESIGN.md index 91666c8..2419e49 100644 --- a/docs/UI_UX_DESIGN.md +++ b/docs/UI_UX_DESIGN.md @@ -213,7 +213,7 @@ application 统一计算远端输入准入,UI 只提供交互意图,平台/r | 最近游戏直启 | `gameid` 请求、真实活动记录和确认目标链路一起交付;不足时回退无历史的 Steam 入口,不放假的可点游戏卡 | | 游戏图片 | SetIcon 仅是来源线索,不能假定有完整库封面 API;图片不可用时用真实游戏名的文字卡,不联网抓任意图片 | | 打开 Steam 菜单 | 单独 capability 检验;适配未获证据前不向发行版绘制可点但无效的菜单项 | -| 画面偏好 | 均衡 720p60/6000kbps,流畅 720p60/4000kbps,清晰 720p60/10000kbps 请求上限;真实协商值单独记录,不承诺 host 一定接受 | +| 画面偏好 | 均衡/流畅/清晰对应协议 Balanced/Fast/Beautiful;独立带宽限制 4/6/10/20 Mbps,默认 6 Mbps。统一请求 H.264 720p60;真实协商值单独记录,不承诺 host 一定接受 | | 自动重连 | 由 #11 的会话恢复设计负责;本规格保证故障可取消/重试/回首页,不借 UI 改造宣称自动重连已实现 | ## 7. 旧实现删除契约 @@ -291,7 +291,7 @@ SDL 与退出的替换需安排有依据的生命周期测试;按 AGENTS.md 返回按钮使用独立控件而非整条大色块。 设置仅保留画面偏好、声音,以及非串流状态下的手动添加电脑。删除连接帮助、菜单快捷键 说明页和仅包一项/串流时为空的高级层级;开流时的一次性快捷键提示和用户手册继续保留。 -声音控制即时本地静音;画面偏好控制下次串流的码率上限。画面偏好采用原生绘制的单选圆点, +声音控制即时本地静音;画面偏好和独立带宽限制在下次串流生效,修改一项不改变另一项。画面偏好采用原生绘制的单选圆点, 不使用字体勾号;实心圆点表示已保存值,白色行表示当前操作焦点,移动焦点不修改保存值。 弹层在 220ms 内位移并渐入,返回时在 160ms 内反向退场;侧栏以不透明表面从右边缘移入(536px 行程), diff --git a/third_party/ihslib b/third_party/ihslib index 3fdbdf4..82ea4e7 160000 --- a/third_party/ihslib +++ b/third_party/ihslib @@ -1 +1 @@ -Subproject commit 3fdbdf4162f1c7d51b5295b406185bc43c7f7f0e +Subproject commit 82ea4e77cae38ec3fbe680595b55fc14f036b55a