using System.Data; using System.Globalization; using System.Net.Http; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; namespace Substation; public record XToysPattern(string Name, List ChannelA, List ChannelB); public static class XToysPatternImporter { static readonly HttpClient Http = new(); public static async Task ImportAsync(string url) { var patternId = ExtractPatternId(url); if (patternId == null) throw new ArgumentException("Could not extract pattern ID from URL. Expected format: https://xtoys.app/patterns/"); var body = JsonSerializer.Serialize(new { data = new { patternID = patternId } }); var content = new StringContent(body, Encoding.UTF8, "application/json"); var response = await Http.PostAsync("https://xtoys.app/api/getPatternv2", content); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(); return Parse(json); } static int FreqPctToMs(byte freqPct) { // xToys frequency: 0% = deep (1000ms), 100% = buzzy (10ms) return (int)Math.Round(1000 - 9.9 * freqPct); } static string? ExtractPatternId(string url) { var match = Regex.Match(url, @"/patterns/([A-Za-z0-9_-]+)"); return match.Success ? match.Groups[1].Value : null; } static XToysPattern Parse(string json) { using var doc = JsonDocument.Parse(json); 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"); 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)) { foreach (var slider in ma.EnumerateArray()) { var key = slider.GetProperty("key").GetString()!; var value = slider.GetProperty("value").GetDouble(); sliders[key] = value; } } return sliders; } static byte[] EvaluateLoops(JsonElement loopsElement, Dictionary sliders) { var values = new List(); double lastEnd = 0; 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 = EvaluateExpression(GetStepValue(step, "time"), sliders); var startVal = step.TryGetProperty("start", out var s) ? (s.ValueKind == JsonValueKind.Null ? lastEnd : EvaluateExpression(GetStepValue(step, "start"), sliders)) : lastEnd; var endVal = EvaluateExpression(GetStepValue(step, "end"), sliders); int ticks = Math.Max(1, (int)Math.Round(time / 0.1)); for (int t = 0; t < ticks; t++) { double frac = (double)t / ticks; double v; if (type == "sine") v = startVal + (endVal - startVal) * (1 - Math.Cos(frac * Math.PI)) / 2; else v = startVal + (endVal - startVal) * frac; values.Add((byte)Math.Clamp((int)Math.Round(v), 0, 100)); } lastEnd = endVal; } } return values.ToArray(); } static string GetStepValue(JsonElement step, string propName) { if (!step.TryGetProperty(propName, out var el)) return "0"; return el.ValueKind switch { JsonValueKind.String => el.GetString() ?? "0", JsonValueKind.Number => el.GetDouble().ToString(CultureInfo.InvariantCulture), _ => "0" }; } static double EvaluateExpression(string expr, Dictionary sliders) { // Replace {var} references with slider values var resolved = Regex.Replace(expr, @"\{(\w+)\}", m => sliders.TryGetValue(m.Groups[1].Value, out var val) ? val.ToString(CultureInfo.InvariantCulture) : "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; } } }