diff --git a/CHANGELOG.md b/CHANGELOG.md index 39fe11e..99199de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Streamn Scoreboard will be documented in this file. ## [Unreleased] +## [0.7.1] - 2026-04-17 + +### Fixed +- Cumulative game clock no longer drifts 1 second from the period clock — elapsed time is now derived from the displayed period clock, guaranteeing they always sum to the period length (#16) +- Cumulative game clock now persists across OBS restarts — previously reset to 0:00 every time OBS reopened + ## [0.7.0] - 2026-04-14 ### Added diff --git a/CMakeLists.txt b/CMakeLists.txt index ef98730..a7ae4b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.21) -project(streamn_obs_scoreboard VERSION 0.7.0 LANGUAGES C CXX) +project(streamn_obs_scoreboard VERSION 0.7.1 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) diff --git a/src/scoreboard-core.c b/src/scoreboard-core.c index b0767a7..d44710f 100644 --- a/src/scoreboard-core.c +++ b/src/scoreboard-core.c @@ -165,6 +165,16 @@ static int parse_clock_text(const char *text) return -1; } +static int parse_cumulative_clock_text(const char *text) +{ + int a = 0, b = 0, c = 0; + if (sscanf(text, "%d:%d:%d", &a, &b, &c) == 3) + return (a * 3600 + b * 60 + c) * 10; /* H:MM:SS */ + if (sscanf(text, "%d:%d", &a, &b) == 2) + return (a * 60 + b) * 10; /* M:SS */ + return -1; +} + static int parse_period_text(const char *text) { /* Search labels array for an exact match */ @@ -531,10 +541,14 @@ int scoreboard_get_period_length(void) static int current_period_elapsed_tenths(void) { + /* Derive elapsed from the displayed (truncated-to-seconds) period + clock so that displayed_period + cumulative == period_length + at every instant. Without this, independent truncation of raw + tenths causes a 1-second drift (GitHub #16). */ + int displayed_seconds = g_state.clock_tenths / 10; if (g_state.clock_direction == SCOREBOARD_CLOCK_COUNT_DOWN) - return g_state.period_length * 10 - g_state.clock_tenths; - else - return g_state.clock_tenths; + return (g_state.period_length - displayed_seconds) * 10; + return displayed_seconds * 10; } void scoreboard_set_game_clock_enabled(bool enabled) @@ -1929,6 +1943,22 @@ bool scoreboard_read_all_files(void) g_state.period_length = val; } + /* Cumulative clock file is optional — restores game clock state. + Must come after clock.txt and period_length.txt since we need + those to compute current-period elapsed and subtract it. */ + if (g_state.game_clock_enabled && + read_text_file(dir, "cumulative_clock.txt", buf, sizeof(buf))) { + int total_tenths = parse_cumulative_clock_text(buf); + if (total_tenths >= 0) { + int current_elapsed = current_period_elapsed_tenths(); + int accumulated = total_tenths - current_elapsed; + if (accumulated < 0) + accumulated = 0; + g_state.game_clock_accumulated_tenths = accumulated; + g_state.game_clock_started = true; + } + } + g_dirty = false; return ok; } diff --git a/tests/test-scoreboard-core-persistence.c b/tests/test-scoreboard-core-persistence.c index 86d8c91..adc2fec 100644 --- a/tests/test-scoreboard-core-persistence.c +++ b/tests/test-scoreboard-core-persistence.c @@ -1422,6 +1422,226 @@ static void test_game_clock_save_load_state(void) cleanup_tmp_dir(); } +static void test_game_clock_read_all_files(void) +{ + /* Simulate OBS restart: write files, reset state, then read them + back. The cumulative clock should be restored. */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); /* 15:00 period */ + scoreboard_clock_reset(); + scoreboard_clock_start(); + scoreboard_clock_tick(900); /* 90 seconds elapsed → clock at 13:30 */ + + bool ok = scoreboard_write_all_files(); + assert(ok); + + /* Verify written cumulative_clock.txt is 1:30 */ + char path[512]; + snprintf(path, sizeof(path), "%s/cumulative_clock.txt", g_tmp_dir); + char *content = read_file_content(path); + assert(content != NULL); + assert(strcmp(content, "1:30") == 0); + free(content); + + /* Reset state (simulates OBS restart calling + scoreboard_reset_state_for_tests + scoreboard_read_all_files) */ + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); + scoreboard_clock_reset(); + ok = scoreboard_read_all_files(); + assert(ok); + + /* Cumulative should be restored: 1:30 = 900 tenths */ + assert(scoreboard_game_clock_get_tenths() == 900); + char gc_buf[32]; + scoreboard_game_clock_format(gc_buf, sizeof(gc_buf)); + assert(strcmp(gc_buf, "1:30") == 0); + + cleanup_tmp_dir(); +} + +static void test_game_clock_read_all_files_across_periods(void) +{ + /* Multi-period cumulative clock should survive a restart. */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(600); /* 10:00 periods */ + scoreboard_clock_reset(); + + /* Period 1: full 10 minutes */ + scoreboard_clock_start(); + scoreboard_clock_tick(6000); + scoreboard_period_advance(); + /* Period 2: 3 minutes elapsed (clock at 7:00) */ + scoreboard_clock_start(); + scoreboard_clock_tick(1800); + + /* Cumulative should be 13:00 = 7800 tenths */ + assert(scoreboard_game_clock_get_tenths() == 7800); + + bool ok = scoreboard_write_all_files(); + assert(ok); + + /* Reset and reload */ + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(600); + scoreboard_clock_reset(); + ok = scoreboard_read_all_files(); + assert(ok); + + assert(scoreboard_game_clock_get_tenths() == 7800); + char gc_buf[32]; + scoreboard_game_clock_format(gc_buf, sizeof(gc_buf)); + assert(strcmp(gc_buf, "13:00") == 0); + + cleanup_tmp_dir(); +} + +static void test_game_clock_read_disabled_no_restore(void) +{ + /* When game clock is disabled, cumulative_clock.txt should be + ignored even if the file exists. */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_clock_start(); + scoreboard_clock_tick(500); + scoreboard_write_all_files(); + + /* Reload with game clock disabled */ + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + /* game_clock_enabled defaults to false */ + scoreboard_clock_reset(); + scoreboard_read_all_files(); + + assert(scoreboard_game_clock_get_tenths() == 0); + + cleanup_tmp_dir(); +} + +static void test_game_clock_read_hmmss_format(void) +{ + /* cumulative_clock.txt with H:MM:SS format should parse correctly */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); + scoreboard_clock_reset(); + + /* Write all mandatory files so read_all_files succeeds */ + scoreboard_mark_dirty(); + bool ok = scoreboard_write_all_files(); + assert(ok); + + /* Overwrite cumulative_clock.txt with H:MM:SS format */ + char path[512]; + snprintf(path, sizeof(path), "%s/cumulative_clock.txt", g_tmp_dir); + FILE *f = fopen(path, "w"); + assert(f != NULL); + fprintf(f, "1:05:30"); + fclose(f); + + /* Reset and reload */ + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); + scoreboard_clock_reset(); + ok = scoreboard_read_all_files(); + assert(ok); + + /* 1:05:30 = 3930 seconds = 39300 tenths. + Clock is at 15:00 (full period), so current elapsed = 0. + accumulated should be 39300. */ + assert(scoreboard_game_clock_get_tenths() == 39300); + + cleanup_tmp_dir(); +} + +static void test_game_clock_read_invalid_format(void) +{ + /* Unparseable cumulative_clock.txt should be silently ignored. */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_clock_reset(); + scoreboard_mark_dirty(); + scoreboard_write_all_files(); + + /* Overwrite with garbage */ + char path[512]; + snprintf(path, sizeof(path), "%s/cumulative_clock.txt", g_tmp_dir); + FILE *f = fopen(path, "w"); + assert(f != NULL); + fprintf(f, "not-a-time"); + fclose(f); + + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_clock_reset(); + scoreboard_read_all_files(); + + /* Should remain at 0 since the file was unparseable */ + assert(scoreboard_game_clock_get_tenths() == 0); + + cleanup_tmp_dir(); +} + +static void test_game_clock_read_clamps_negative(void) +{ + /* If cumulative_clock.txt has a value smaller than the current + period elapsed, accumulated should clamp to 0. */ + scoreboard_reset_state_for_tests(); + setup_tmp_dir(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); /* 15:00 */ + scoreboard_clock_reset(); + scoreboard_clock_start(); + scoreboard_clock_tick(3000); /* 5 minutes elapsed, clock at 10:00 */ + scoreboard_mark_dirty(); + scoreboard_write_all_files(); + + /* Overwrite cumulative_clock.txt with a value less than + the current-period elapsed (5:00 → 3000 tenths) */ + char path[512]; + snprintf(path, sizeof(path), "%s/cumulative_clock.txt", g_tmp_dir); + FILE *f = fopen(path, "w"); + assert(f != NULL); + fprintf(f, "0:30"); /* 30 seconds < 5 minutes elapsed */ + fclose(f); + + scoreboard_reset_state_for_tests(); + scoreboard_set_output_directory(g_tmp_dir); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(900); + scoreboard_clock_reset(); + scoreboard_read_all_files(); + + /* accumulated would be 300 - 3000 = -2700 → clamped to 0. + get_tenths = 0 + current_elapsed (3000) = 3000. + But wait — clock.txt was written as 10:00 so clock_tenths + should be 6000. current_elapsed = (900 - 600)*10 = 3000. */ + int tenths = scoreboard_game_clock_get_tenths(); + assert(tenths == 3000); + + cleanup_tmp_dir(); +} + static void test_penalty_labels_write_files(void) { scoreboard_reset_state_for_tests(); @@ -1551,6 +1771,12 @@ int main(void) test_game_clock_write_file(); test_game_clock_no_file_when_disabled(); test_game_clock_save_load_state(); + test_game_clock_read_all_files(); + test_game_clock_read_all_files_across_periods(); + test_game_clock_read_disabled_no_restore(); + test_game_clock_read_hmmss_format(); + test_game_clock_read_invalid_format(); + test_game_clock_read_clamps_negative(); test_penalty_labels_write_files(); test_penalty_labels_write_empty(); diff --git a/tests/test-scoreboard-core.c b/tests/test-scoreboard-core.c index 577ea1c..aa29420 100644 --- a/tests/test-scoreboard-core.c +++ b/tests/test-scoreboard-core.c @@ -995,6 +995,79 @@ static void test_game_clock_format_hmmss(void) assert(strcmp(buf, "1:01:33") == 0); } +static void test_game_clock_subsecond_sync(void) +{ + /* Cumulative clock must always satisfy: + displayed_period + displayed_cumulative == period_length + Before the fix, independent truncation of raw tenths caused a + 1-second discrepancy (GitHub issue #16). The fix derives + elapsed from the displayed period-clock seconds. */ + scoreboard_reset_state_for_tests(); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(1200); /* 20:00 countdown */ + scoreboard_clock_reset(); + scoreboard_clock_start(); + + /* Tick 1 tenth (clock_tenths 12000 → 11999). + Period displays 11999/10 = 1199s = 19:59. + Elapsed = (1200 - 1199) * 10 = 10 → cumulative 0:01. + 19:59 + 0:01 = 20:00 ✓ */ + scoreboard_clock_tick(1); + assert(scoreboard_game_clock_get_tenths() == 10); + + /* Tick 8 more (total 9, clock_tenths = 11991). + Period still 11991/10 = 1199s = 19:59. + Cumulative still 0:01. 19:59 + 0:01 = 20:00 ✓ */ + scoreboard_clock_tick(8); + assert(scoreboard_game_clock_get_tenths() == 10); + + /* Tick 1 more to cross the second boundary (total 10, + clock_tenths = 11990). Period 11990/10 = 1199s = 19:59. + Still 0:01. 19:59 + 0:01 = 20:00 ✓ */ + scoreboard_clock_tick(1); + assert(scoreboard_game_clock_get_tenths() == 10); + + /* Tick 11 more (total 21, clock_tenths = 11979). + Period 11979/10 = 1197s = 19:57. + Elapsed = (1200 - 1197) * 10 = 30 → cumulative 0:03. + 19:57 + 0:03 = 20:00 ✓ */ + scoreboard_clock_tick(11); + assert(scoreboard_game_clock_get_tenths() == 30); + + /* Verify formatted strings: 19:57 + 0:03 = 20:00 */ + char clock_buf[32], cum_buf[32]; + scoreboard_clock_format(clock_buf, sizeof(clock_buf)); + scoreboard_game_clock_format(cum_buf, sizeof(cum_buf)); + assert(strcmp(clock_buf, "19:57") == 0); + assert(strcmp(cum_buf, "0:03") == 0); +} + +static void test_game_clock_subsecond_sync_across_periods(void) +{ + /* Ensure the invariant holds across period boundaries when the + clock is mid-second at period advance. */ + scoreboard_reset_state_for_tests(); + scoreboard_set_game_clock_enabled(true); + scoreboard_set_period_length(60); /* 1:00 periods */ + scoreboard_clock_reset(); + scoreboard_clock_start(); + + /* Tick 5 tenths (clock_tenths 600 → 595). + Period displays 595/10 = 59s = 0:59. + Elapsed = (60 - 59) * 10 = 10. + Period advance accumulates 10. */ + scoreboard_clock_tick(5); + scoreboard_period_advance(); + + /* Period 2: tick 10 tenths (clock_tenths 600 → 590). + Period displays 590/10 = 59s = 0:59. + Elapsed = (60 - 59) * 10 = 10. + Cumulative = 10 (accumulated) + 10 (current) = 20 → 0:02. */ + scoreboard_clock_start(); + scoreboard_clock_tick(10); + assert(scoreboard_game_clock_get_tenths() == 20); +} + static void test_game_clock_format_mmss_over_hour(void) { /* MM:SS format — minutes exceed 59 */ @@ -1102,6 +1175,8 @@ int main(void) test_game_clock_format_null(); test_game_clock_format_disabled(); test_game_clock_format_hmmss(); + test_game_clock_subsecond_sync(); + test_game_clock_subsecond_sync_across_periods(); test_game_clock_format_mmss_over_hour(); printf("All scoreboard-core clock/period tests passed.\n");