From 496cc5a6b24b2a768646e088c5d485434bcf58b2 Mon Sep 17 00:00:00 2001 From: Mute Date: Fri, 14 Aug 2026 09:49:58 +0000 Subject: [PATCH] Support all 5 xToys pattern types - script-v3: reworked with initialActions channel mapping, freq detection by name, single-channel duplication, full expression evaluator (pow, arithmetic) - basic-v2: handle frequencyControl:true, 3-channel (ch3 ignored) - funscript/draw: resample {at,pos} timeline to 100ms ticks via linear interpolation - audio: resample flat float array to 100ms ticks using length in seconds - EvaluateExpression replaces ResolveExpr: DataTable.Compute + pow() pre-processing - All types: single-channel patterns duplicate to both A/B - All types: missing frequency data uses fixed 150ms default --- XToysPattern.cs | 414 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 361 insertions(+), 53 deletions(-) diff --git a/XToysPattern.cs b/XToysPattern.cs index 1b6cd04..e9671fe 100644 --- a/XToysPattern.cs +++ b/XToysPattern.cs @@ -1,3 +1,5 @@ +using System.Data; +using System.Globalization; using System.Net.Http; using System.Text; using System.Text.Json; @@ -29,7 +31,6 @@ public static class XToysPatternImporter static int FreqPctToMs(byte freqPct) { // xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms) - // freqPct is the raw 0-100 value from the pattern (already clamped in EvaluatePattern) return (int)Math.Round(1000 - 9.9 * freqPct); } @@ -45,9 +46,332 @@ public static class XToysPatternImporter var pattern = doc.RootElement.GetProperty("result").GetProperty("pattern"); var name = pattern.GetProperty("name").GetString() ?? "Unknown"; + var patternType = pattern.GetProperty("type").GetString() ?? "script-v3"; var patternData = pattern.GetProperty("patternData"); - // Resolve slider defaults + return patternType switch + { + "basic-v2" => ParseBasicV2(name, patternData), + "funscript" => ParseFunscript(name, patternData), + "draw" => ParseFunscript(name, patternData), + "audio" => ParseAudio(name, pattern, patternData), + "script-v3" => ParseScriptV3(name, patternData), + _ => ParseScriptV3(name, patternData) + }; + } + + // ── script-v3: slider-driven, arbitrary pattern names, initialActions channel mapping ── + + static XToysPattern ParseScriptV3(string name, JsonElement patternData) + { + var sliders = ResolveSliders(patternData); + bool hasFreq = patternData.TryGetProperty("frequencyControl", out var fc) && fc.GetBoolean(); + int channelCount = patternData.TryGetProperty("channels", out var cc) ? cc.GetInt32() : 2; + + // Parse initialActions to map pattern names → channels + var patternToChannel = new Dictionary(); + if (patternData.TryGetProperty("initialActions", out var actions)) + { + foreach (var action in actions.EnumerateArray()) + { + if (action.TryGetProperty("type", out var t) && t.GetString() == "updatePattern") + { + var patName = action.GetProperty("pattern").GetString()!; + var channels = action.GetProperty("channels"); + int ch = 1; + if (channels.TryGetProperty("1", out var c1) && c1.GetBoolean()) ch = 1; + else if (channels.TryGetProperty("2", out var c2) && c2.GetBoolean()) ch = 2; + patternToChannel[patName] = ch; + } + } + } + + // Get all pattern names + var patterns = patternData.GetProperty("patterns"); + var patternNames = new List(); + foreach (var prop in patterns.EnumerateObject()) + patternNames.Add(prop.Name); + + // Classify patterns: frequency (name contains "freq") vs intensity + var intPatterns = new List<(string name, int channel)>(); + var freqPatterns = new List<(string name, int channel)>(); + + foreach (var pname in patternNames) + { + int ch = patternToChannel.GetValueOrDefault(pname, 1); + if (hasFreq && pname.Contains("freq", StringComparison.OrdinalIgnoreCase)) + freqPatterns.Add((pname, ch)); + else + intPatterns.Add((pname, ch)); + } + + // Evaluate patterns + var intByChannel = new Dictionary(); + var freqByChannel = new Dictionary(); + + foreach (var (pname, ch) in intPatterns) + intByChannel[ch] = EvaluateLoops(patterns.GetProperty(pname), sliders); + + foreach (var (pname, ch) in freqPatterns) + freqByChannel[ch] = EvaluateLoops(patterns.GetProperty(pname), sliders); + + // Get arrays for each channel (default to empty) + var intA = intByChannel.GetValueOrDefault(1, Array.Empty()); + var intB = channelCount >= 2 + ? intByChannel.GetValueOrDefault(2, intA) + : intA; // single channel → duplicate + var freqA = freqByChannel.GetValueOrDefault(1, Array.Empty()); + var freqB = channelCount >= 2 + ? freqByChannel.GetValueOrDefault(2, freqA) + : freqA; + + int tickCount = Math.Max(intA.Length, Math.Max(intB.Length, Math.Max(freqA.Length, freqB.Length))); + if (tickCount == 0) tickCount = 1; + + byte defaultFreq = Freq.Compress(150); + var channelA = new List(tickCount); + var channelB = new List(tickCount); + + for (int i = 0; i < tickCount; i++) + { + byte ia = i < intA.Length ? intA[i] : (byte)0; + byte ib = i < intB.Length ? intB[i] : (byte)0; + byte fa = i < freqA.Length ? Freq.Compress(FreqPctToMs(freqA[i])) : defaultFreq; + byte fb = i < freqB.Length ? Freq.Compress(FreqPctToMs(freqB[i])) : defaultFreq; + + channelA.Add(new WaveFrame(new[] { fa, fa, fa, fa }, new[] { ia, ia, ia, ia })); + channelB.Add(new WaveFrame(new[] { fb, fb, fb, fb }, new[] { ib, ib, ib, ib })); + } + + return new XToysPattern(name, channelA, channelB); + } + + // ── basic-v2: channelData with loops/steps, optional frequencyControl ── + + static XToysPattern ParseBasicV2(string name, JsonElement patternData) + { + var emptySliders = new Dictionary(); + const int defaultFreqMs = 150; + bool hasFreq = patternData.TryGetProperty("frequencyControl", out var fc) && fc.GetBoolean(); + + byte freqByte = Freq.Compress(defaultFreqMs); + + var intA = Array.Empty(); + var intB = Array.Empty(); + var freqA = Array.Empty(); + var freqB = Array.Empty(); + + if (patternData.TryGetProperty("channelData", out var cd)) + { + if (cd.TryGetProperty("1", out var ch1)) + { + intA = EvaluateLoops(ch1, emptySliders); + if (hasFreq) freqA = intA; // basic-v2 with freq: same data is freq + } + if (cd.TryGetProperty("2", out var ch2)) + { + intB = EvaluateLoops(ch2, emptySliders); + if (hasFreq) freqB = intB; + } + } + + // Single channel → duplicate + if (intB.Length == 0 && intA.Length > 0) + { + intB = intA; + freqB = freqA; + } + if (intA.Length == 0 && intB.Length > 0) + { + intA = intB; + freqA = freqB; + } + + int tickCount = Math.Max(intA.Length, intB.Length); + if (tickCount == 0) tickCount = 1; + + var channelA = new List(tickCount); + var channelB = new List(tickCount); + + for (int i = 0; i < tickCount; i++) + { + byte ia = i < intA.Length ? intA[i] : (byte)0; + byte ib = i < intB.Length ? intB[i] : (byte)0; + byte fa = hasFreq && i < freqA.Length ? Freq.Compress(FreqPctToMs(freqA[i])) : freqByte; + byte fb = hasFreq && i < freqB.Length ? Freq.Compress(FreqPctToMs(freqB[i])) : freqByte; + + channelA.Add(new WaveFrame(new[] { fa, fa, fa, fa }, new[] { ia, ia, ia, ia })); + channelB.Add(new WaveFrame(new[] { fb, fb, fb, fb }, new[] { ib, ib, ib, ib })); + } + + return new XToysPattern(name, channelA, channelB); + } + + // ── funscript / draw: {at, pos} timeline, resample to 100ms ticks ── + + static XToysPattern ParseFunscript(string name, JsonElement patternData) + { + byte freqByte = Freq.Compress(150); + + var intA = Array.Empty(); + var intB = Array.Empty(); + + if (patternData.TryGetProperty("channelData", out var cd)) + { + if (cd.TryGetProperty("1", out var ch1)) + intA = ResampleTimeline(ch1); + if (cd.TryGetProperty("2", out var ch2)) + intB = ResampleTimeline(ch2); + } + + // Single channel → duplicate + if (intB.Length == 0 && intA.Length > 0) intB = intA; + if (intA.Length == 0 && intB.Length > 0) intA = intB; + + int tickCount = Math.Max(intA.Length, intB.Length); + if (tickCount == 0) tickCount = 1; + + var channelA = new List(tickCount); + var channelB = new List(tickCount); + + for (int i = 0; i < tickCount; i++) + { + byte ia = i < intA.Length ? intA[i] : (byte)0; + byte ib = i < intB.Length ? intB[i] : (byte)0; + + channelA.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ia, ia, ia, ia })); + channelB.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ib, ib, ib, ib })); + } + + return new XToysPattern(name, channelA, channelB); + } + + static byte[] ResampleTimeline(JsonElement channelData) + { + // channelData is an array of {at, pos} objects (at in ms, pos 0-100) + var points = new List<(double at, double pos)>(); + foreach (var pt in channelData.EnumerateArray()) + { + double at = pt.GetProperty("at").GetDouble(); + double pos = pt.GetProperty("pos").GetDouble(); + points.Add((at, pos)); + } + + if (points.Count == 0) return Array.Empty(); + if (points.Count == 1) return new[] { (byte)Math.Clamp((int)Math.Round(points[0].pos), 0, 100) }; + + double durationMs = points[^1].at; + int tickCount = Math.Max(1, (int)Math.Ceiling(durationMs / 100.0)); + + var result = new byte[tickCount]; + int ptIdx = 0; + + for (int t = 0; t < tickCount; t++) + { + double tickMs = t * 100.0; + + // Advance to the right pair of points + while (ptIdx < points.Count - 2 && points[ptIdx + 1].at < tickMs) + ptIdx++; + + // Linear interpolation between points[ptIdx] and points[ptIdx + 1] + double pos; + if (points[ptIdx + 1].at == points[ptIdx].at) + pos = points[ptIdx].pos; + else + { + double frac = (tickMs - points[ptIdx].at) / (points[ptIdx + 1].at - points[ptIdx].at); + frac = Math.Clamp(frac, 0, 1); + pos = points[ptIdx].pos + (points[ptIdx + 1].pos - points[ptIdx].pos) * frac; + } + + result[t] = (byte)Math.Clamp((int)Math.Round(pos), 0, 100); + } + + return result; + } + + // ── audio: flat float array, resample to 100ms ticks ── + + static XToysPattern ParseAudio(string name, JsonElement pattern, JsonElement patternData) + { + byte freqByte = Freq.Compress(150); + + // Length is in seconds (from pattern.length or patternData.length) + double lengthSec = 0; + if (pattern.TryGetProperty("length", out var lenEl) && lenEl.ValueKind == JsonValueKind.Number) + lengthSec = lenEl.GetDouble(); + else if (pattern.TryGetProperty("length", out var lenStr) && lenStr.ValueKind == JsonValueKind.String) + double.TryParse(lenStr.GetString(), out lengthSec); + + double scale = patternData.TryGetProperty("scale", out var scaleEl) ? scaleEl.GetDouble() : 100; + + var intA = Array.Empty(); + var intB = Array.Empty(); + + if (patternData.TryGetProperty("channelData", out var cd)) + { + if (cd.TryGetProperty("1", out var ch1)) + intA = ResampleAudio(ch1, scale, lengthSec); + if (cd.TryGetProperty("2", out var ch2)) + intB = ResampleAudio(ch2, scale, lengthSec); + } + + // Single channel → duplicate + if (intB.Length == 0 && intA.Length > 0) intB = intA; + if (intA.Length == 0 && intB.Length > 0) intA = intB; + + int tickCount = Math.Max(intA.Length, intB.Length); + if (tickCount == 0) tickCount = 1; + + var channelA = new List(tickCount); + var channelB = new List(tickCount); + + for (int i = 0; i < tickCount; i++) + { + byte ia = i < intA.Length ? intA[i] : (byte)0; + byte ib = i < intB.Length ? intB[i] : (byte)0; + + channelA.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ia, ia, ia, ia })); + channelB.Add(new WaveFrame(new[] { freqByte, freqByte, freqByte, freqByte }, new[] { ib, ib, ib, ib })); + } + + return new XToysPattern(name, channelA, channelB); + } + + static byte[] ResampleAudio(JsonElement channelData, double scale, double lengthSec) + { + // channelData is a flat array of float values (raw, need to divide by scale) + var samples = new List(); + foreach (var s in channelData.EnumerateArray()) + samples.Add(s.GetDouble() / scale * 100.0); + + if (samples.Count == 0) return Array.Empty(); + + // Determine tick count from length (seconds → 100ms ticks) + int tickCount; + if (lengthSec > 0) + tickCount = Math.Max(1, (int)Math.Ceiling(lengthSec * 10)); + else + tickCount = samples.Count; // fallback: 1 sample per tick + + var result = new byte[tickCount]; + + for (int t = 0; t < tickCount; t++) + { + // Map tick to sample index + int idx = (int)Math.Round((double)t * samples.Count / tickCount); + if (idx >= samples.Count) idx = samples.Count - 1; + result[t] = (byte)Math.Clamp((int)Math.Round(samples[idx]), 0, 100); + } + + return result; + } + + // ── Shared helpers ── + + static Dictionary ResolveSliders(JsonElement patternData) + { var sliders = new Dictionary(); if (patternData.TryGetProperty("ma", out var ma)) { @@ -58,61 +382,25 @@ public static class XToysPatternImporter sliders[key] = value; } } - - // Evaluate patterns to per-tick value arrays - var int1 = EvaluatePattern(patternData, "Int1", sliders); - var int2 = EvaluatePattern(patternData, "Int2", sliders); - var freq1 = EvaluatePattern(patternData, "Freq1", sliders); - var freq2 = EvaluatePattern(patternData, "Freq2", sliders); - - // All patterns in a group should have the same total duration. - // Use the max length to be safe. - int tickCount = Math.Max(int1.Length, Math.Max(int2.Length, Math.Max(freq1.Length, freq2.Length))); - - var channelA = new List(tickCount); - var channelB = new List(tickCount); - - for (int i = 0; i < tickCount; i++) - { - byte intA = i < int1.Length ? int1[i] : (byte)0; - byte intB = i < int2.Length ? int2[i] : (byte)0; - // Freq patterns output 0-100 (percentage). Map: 0% = 1000ms deep, 100% = 10ms buzzy. - byte freqA = i < freq1.Length ? Freq.Compress(FreqPctToMs(freq1[i])) : Freq.Compress(10); - byte freqB = i < freq2.Length ? Freq.Compress(FreqPctToMs(freq2[i])) : Freq.Compress(10); - - channelA.Add(new WaveFrame( - new[] { freqA, freqA, freqA, freqA }, - new[] { intA, intA, intA, intA })); - - channelB.Add(new WaveFrame( - new[] { freqB, freqB, freqB, freqB }, - new[] { intB, intB, intB, intB })); - } - - return new XToysPattern(name, channelA, channelB); + return sliders; } - static byte[] EvaluatePattern(JsonElement patternData, string patternName, Dictionary sliders) + static byte[] EvaluateLoops(JsonElement loopsElement, Dictionary sliders) { - if (!patternData.TryGetProperty("patterns", out var patterns)) - return Array.Empty(); - if (!patterns.TryGetProperty(patternName, out var patternArr)) - return Array.Empty(); - var values = new List(); double lastEnd = 0; - foreach (var loop in patternArr.EnumerateArray()) + foreach (var loop in loopsElement.EnumerateArray()) { var steps = loop.GetProperty("steps"); foreach (var step in steps.EnumerateArray()) { var type = step.GetProperty("type").GetString() ?? "straight"; - var time = ResolveExpr(GetStepValue(step, "time"), sliders); + var time = EvaluateExpression(GetStepValue(step, "time"), sliders); var startVal = step.TryGetProperty("start", out var s) - ? (s.ValueKind == JsonValueKind.Null ? lastEnd : ResolveExpr(GetStepValue(step, "start"), sliders)) + ? (s.ValueKind == JsonValueKind.Null ? lastEnd : EvaluateExpression(GetStepValue(step, "start"), sliders)) : lastEnd; - var endVal = ResolveExpr(GetStepValue(step, "end"), sliders); + var endVal = EvaluateExpression(GetStepValue(step, "end"), sliders); int ticks = Math.Max(1, (int)Math.Round(time / 0.1)); @@ -142,21 +430,41 @@ public static class XToysPatternImporter return el.ValueKind switch { JsonValueKind.String => el.GetString() ?? "0", - JsonValueKind.Number => el.GetDouble().ToString(), + JsonValueKind.Number => el.GetDouble().ToString(CultureInfo.InvariantCulture), _ => "0" }; } - static double ResolveExpr(string expr, Dictionary sliders) + static double EvaluateExpression(string expr, Dictionary sliders) { - // Replace {var} references, then evaluate simple expressions like "ramp/2" + // Replace {var} references with slider values var resolved = Regex.Replace(expr, @"\{(\w+)\}", m => - sliders.TryGetValue(m.Groups[1].Value, out var val) ? val.ToString() : "0"); + sliders.TryGetValue(m.Groups[1].Value, out var val) + ? val.ToString(CultureInfo.InvariantCulture) + : "0"); - // Parse simple arithmetic: number, or number/number - var parts = resolved.Split('/'); - if (parts.Length == 2 && double.TryParse(parts[0], out var a) && double.TryParse(parts[1], out var b)) - return a / b; - return double.TryParse(resolved, out var result) ? result : 0; + // Try plain number first + if (double.TryParse(resolved, CultureInfo.InvariantCulture, out var simple)) + return simple; + + // Pre-process pow(a, b) calls — DataTable.Compute doesn't support pow + resolved = Regex.Replace(resolved, @"pow\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)", m => + { + double a = double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture); + double b = double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture); + return Math.Pow(a, b).ToString("G15", CultureInfo.InvariantCulture); + }); + + // Evaluate with DataTable.Compute (handles +, -, *, /, parentheses) + try + { + var result = new DataTable().Compute(resolved, null); + return Convert.ToDouble(result, CultureInfo.InvariantCulture); + } + catch + { + // Fallback: try simple number + return double.TryParse(resolved, CultureInfo.InvariantCulture, out var fallback) ? fallback : 0; + } } }