From f6baade35e147b7c305bb6f7e55a9ddd506bd7d0 Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Wed, 22 Jul 2026 20:45:54 +0200 Subject: [PATCH] Parse OpenSplit Metadata Parse OpenSplit platform, speedrun.com variables, and world record information into run metadata. Store available world record times using the existing permanent custom variable convention and keep format-specific coverage alongside the parser. Also remove redundant formatting borrows throughout the C API binding generator so it passes current Clippy with warnings denied. --- capi/bind_gen/src/csharp.rs | 4 +- capi/bind_gen/src/java/jna.rs | 2 +- capi/bind_gen/src/jni_cpp.rs | 2 +- capi/bind_gen/src/node.rs | 2 +- capi/bind_gen/src/python.rs | 2 +- capi/bind_gen/src/ruby.rs | 2 +- capi/bind_gen/src/swift/code.rs | 2 +- capi/bind_gen/src/wasm_bindgen.rs | 2 +- src/run/parser/opensplit.rs | 169 +++++++++++++++++++++++++++++- tests/split_parsing.rs | 52 +-------- 10 files changed, 181 insertions(+), 58 deletions(-) diff --git a/capi/bind_gen/src/csharp.rs b/capi/bind_gen/src/csharp.rs index 2afd59ca7..22910ebaa 100644 --- a/capi/bind_gen/src/csharp.rs +++ b/capi/bind_gen/src/csharp.rs @@ -199,7 +199,7 @@ fn write_fn(mut writer: W, function: &Function, class_name: &str) -> R } } - write!(writer, r#"LiveSplitCoreNative.{}("#, &function.name)?; + write!(writer, r#"LiveSplitCoreNative.{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { if i != 0 { @@ -455,7 +455,7 @@ namespace LiveSplitCore [DllImport("livesplit_core", CallingConvention = CallingConvention.Cdecl)] public static extern {} {}("#, get_ll_type(&function.output, true), - &function.name + function.name )?; for (i, (name, typ)) in function.inputs.iter().enumerate() { diff --git a/capi/bind_gen/src/java/jna.rs b/capi/bind_gen/src/java/jna.rs index 93e29813e..8386e44c8 100644 --- a/capi/bind_gen/src/java/jna.rs +++ b/capi/bind_gen/src/java/jna.rs @@ -171,7 +171,7 @@ fn write_fn(mut writer: W, function: &Function, class_name: &str) -> R write!( writer, r#"LiveSplitCoreNative.INSTANCE.{}("#, - &function.name + function.name )?; for (i, (name, typ)) in function.inputs.iter().enumerate() { diff --git a/capi/bind_gen/src/jni_cpp.rs b/capi/bind_gen/src/jni_cpp.rs index 1926bc5c8..d7082bdbc 100644 --- a/capi/bind_gen/src/jni_cpp.rs +++ b/capi/bind_gen/src/jni_cpp.rs @@ -113,7 +113,7 @@ extern "C" JNIEXPORT {} Java_livesplitcore_LiveSplitCoreNative_{}_1{}(JNIEnv* jn } } - write!(writer, r#"{}("#, &function.name)?; + write!(writer, r#"{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { if i != 0 { diff --git a/capi/bind_gen/src/node.rs b/capi/bind_gen/src/node.rs index 5767f9cf0..cf2f387ce 100644 --- a/capi/bind_gen/src/node.rs +++ b/capi/bind_gen/src/node.rs @@ -221,7 +221,7 @@ fn write_fn(mut writer: W, function: &Function, type_script: bool) -> } } - write!(writer, r#"liveSplitCoreNative.{}("#, &function.name)?; + write!(writer, r#"liveSplitCoreNative.{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { if i != 0 { diff --git a/capi/bind_gen/src/python.rs b/capi/bind_gen/src/python.rs index 21432e50d..0e3a50c85 100644 --- a/capi/bind_gen/src/python.rs +++ b/capi/bind_gen/src/python.rs @@ -144,7 +144,7 @@ fn write_fn(mut writer: W, function: &Function) -> Result<()> { } } - write!(writer, r#"livesplit_core_native.{}("#, &function.name)?; + write!(writer, r#"livesplit_core_native.{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { if i != 0 { diff --git a/capi/bind_gen/src/ruby.rs b/capi/bind_gen/src/ruby.rs index 52d049988..b06064fe9 100644 --- a/capi/bind_gen/src/ruby.rs +++ b/capi/bind_gen/src/ruby.rs @@ -187,7 +187,7 @@ fn write_fn(mut writer: W, function: &Function) -> Result<()> { } } - write!(writer, r#"Native.{}("#, &function.name)?; + write!(writer, r#"Native.{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { if i != 0 { diff --git a/capi/bind_gen/src/swift/code.rs b/capi/bind_gen/src/swift/code.rs index 9e7e6e418..b977b5495 100644 --- a/capi/bind_gen/src/swift/code.rs +++ b/capi/bind_gen/src/swift/code.rs @@ -168,7 +168,7 @@ fn write_fn(mut writer: W, function: &Function) -> Result<()> { write!(writer, "let result = ")?; } - write!(writer, "CLiveSplitCore.{}(", &function.name)?; + write!(writer, "CLiveSplitCore.{}(", function.name)?; for (i, (name, ty)) in function.inputs.iter().enumerate() { if i != 0 { diff --git a/capi/bind_gen/src/wasm_bindgen.rs b/capi/bind_gen/src/wasm_bindgen.rs index 8150281ce..5b427d37b 100644 --- a/capi/bind_gen/src/wasm_bindgen.rs +++ b/capi/bind_gen/src/wasm_bindgen.rs @@ -222,7 +222,7 @@ fn write_fn(mut writer: W, function: &Function, type_script: bool) -> } } - write!(writer, r#"wasm.{}("#, &function.name)?; + write!(writer, r#"wasm.{}("#, function.name)?; for (i, (name, typ)) in function.inputs.iter().enumerate() { let type_name = get_hl_type_without_null(typ); diff --git a/src/run/parser/opensplit.rs b/src/run/parser/opensplit.rs index 0c70dc30c..dfee6798e 100644 --- a/src/run/parser/opensplit.rs +++ b/src/run/parser/opensplit.rs @@ -3,7 +3,11 @@ // https://github.com/ZellyDev-Games/OpenSplit use crate::{ - Run, Segment, Time, TimeSpan, platform::prelude::*, run::SegmentGroup, run::SegmentGroups, + Lang, Run, Segment, Time, TimeSpan, + comparison::world_record, + platform::prelude::*, + run::{SegmentGroup, SegmentGroups}, + timing::formatter::{self, TimeFormatter}, }; use alloc::{borrow::Cow, collections::BTreeMap}; use core::result::Result as StdResult; @@ -41,6 +45,23 @@ struct SplitFilePayload<'a> { offset: i64, #[serde(default, borrow)] platform: Cow<'a, str>, + #[serde(default, borrow)] + variables: Vec>, + #[serde(default)] + wr: Option, +} + +#[derive(Deserialize)] +struct VariablePayload<'a> { + #[serde(borrow)] + name: Cow<'a, str>, + #[serde(borrow)] + label: Cow<'a, str>, +} + +#[derive(Deserialize)] +struct WorldRecordPayload { + real_time: f64, } #[derive(Deserialize)] @@ -139,7 +160,26 @@ pub fn parse(source: &str) -> Result { run.set_category_name(splits.game_category); run.set_attempt_count(splits.attempts); run.set_offset(integer_time(splits.offset)); - run.metadata_mut().set_platform_name(splits.platform); + + let metadata = run.metadata_mut(); + metadata.set_platform_name(splits.platform); + for variable in splits.variables { + metadata.set_speedrun_com_variable(variable.name, variable.label); + } + if let Some(wr) = splits.wr + && wr.real_time > 0.0 + { + metadata + .custom_variable_mut(world_record::NAME) + .permanent() + // FIXME: This should probably depend on the locale or: + // FIXME: Custom variables should support TimeSpans directly. + .set_value( + formatter::Regular::new() + .format(Some(TimeSpan::from_seconds(wr.real_time)), Lang::English) + .to_string(), + ); + } let FlattenedSegments { segments: leaf_segments, @@ -201,3 +241,128 @@ pub fn parse(source: &str) -> Result { Ok(run) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_speedrun_com_metadata() { + let run = parse( + r#"{ + "game_name": "Game", + "speedrun_game_id": "game-id", + "game_category": "Any%", + "speedrun_game_category_id": "category-id", + "attempts": 0, + "platform": "GameCube", + "variables": [ + { + "id": "difficulty-id", + "name": "Difficulty", + "value": "hard-id", + "label": "Hard" + }, + { + "id": "players-id", + "name": "Players", + "value": "one-player-id", + "label": "1 Player" + } + ], + "wr": { + "show": true, + "run_id": "world-record-run-id", + "players": ["Runner"], + "real_time": 3723.456, + "in_game_time": 3600.0 + }, + "segments": [] + }"#, + ) + .unwrap(); + + let metadata = run.metadata(); + assert_eq!(metadata.platform_name(), "GameCube"); + assert_eq!( + metadata + .speedrun_com_variables() + .map(|(name, value)| (name, value.as_str())) + .collect::>(), + [("Difficulty", "Hard"), ("Players", "1 Player")] + ); + + let world_record = metadata.custom_variable(world_record::NAME).unwrap(); + assert_eq!(world_record.value, "1:02:03"); + assert!(world_record.is_permanent); + } + + #[test] + fn ignores_unavailable_world_record() { + let run = parse( + r#"{ + "game_name": "Game", + "game_category": "Any%", + "attempts": 0, + "wr": { + "show": false, + "run_id": "", + "players": [], + "real_time": 0, + "in_game_time": 0 + }, + "segments": [] + }"#, + ) + .unwrap(); + + assert!(run.metadata().custom_variable(world_record::NAME).is_none()); + } + + #[test] + fn segment_children_become_segment_groups() { + let run = parse( + r#"{ + "game_name": "Game", + "game_category": "Any%", + "attempts": 0, + "segments": [ + { "id": "intro", "name": "Intro", "gold": 0, "pb": 0 }, + { + "id": "chapter", + "name": "Chapter", + "gold": 0, + "pb": 0, + "children": [ + { "id": "a", "name": "A", "gold": 0, "pb": 0 }, + { + "id": "nested", + "name": "Nested", + "gold": 0, + "pb": 0, + "children": [ + { "id": "b", "name": "B", "gold": 0, "pb": 0 }, + { "id": "c", "name": "C", "gold": 0, "pb": 0 } + ] + } + ] + }, + { "id": "outro", "name": "Outro", "gold": 0, "pb": 0 } + ] + }"#, + ) + .unwrap(); + + assert_eq!( + run.segments() + .iter() + .map(|segment| segment.name()) + .collect::>(), + ["Intro", "A", "B", "C", "Outro"] + ); + assert_eq!(run.segment_groups().groups().len(), 1); + let group = &run.segment_groups().groups()[0]; + assert_eq!((group.start(), group.end()), (1, 4)); + assert_eq!(group.name(), Some("Chapter")); + } +} diff --git a/tests/split_parsing.rs b/tests/split_parsing.rs index 7a0bd3f41..dba2b112b 100644 --- a/tests/split_parsing.rs +++ b/tests/split_parsing.rs @@ -1,3 +1,8 @@ +//! Integration tests for parsing representative real-world splits files and +//! detecting their timer kinds. The files in `run_files` are reserved for this +//! suite. Tests for specific fields, edge cases, and parser semantics should use +//! inline inputs in the corresponding parser's local test module. + mod run_files; mod parse { @@ -272,53 +277,6 @@ mod parse { opensplit::parse(run_files::OPENSPLIT).unwrap(); } - #[test] - fn opensplit_segment_children_become_segment_groups() { - let run = opensplit::parse( - r#"{ - "game_name": "Game", - "game_category": "Any%", - "attempts": 0, - "segments": [ - { "id": "intro", "name": "Intro", "gold": 0, "pb": 0 }, - { - "id": "chapter", - "name": "Chapter", - "gold": 0, - "pb": 0, - "children": [ - { "id": "a", "name": "A", "gold": 0, "pb": 0 }, - { - "id": "nested", - "name": "Nested", - "gold": 0, - "pb": 0, - "children": [ - { "id": "b", "name": "B", "gold": 0, "pb": 0 }, - { "id": "c", "name": "C", "gold": 0, "pb": 0 } - ] - } - ] - }, - { "id": "outro", "name": "Outro", "gold": 0, "pb": 0 } - ] - }"#, - ) - .unwrap(); - - assert_eq!( - run.segments() - .iter() - .map(|segment| segment.name()) - .collect::>(), - ["Intro", "A", "B", "C", "Outro"] - ); - assert_eq!(run.segment_groups().groups().len(), 1); - let group = &run.segment_groups().groups()[0]; - assert_eq!((group.start(), group.end()), (1, 4)); - assert_eq!(group.name(), Some("Chapter")); - } - #[test] fn speedrun_igt_prefers_parsing_as_itself() { let run = composite::parse(run_files::SPEEDRUN_IGT.as_bytes(), None).unwrap();