OFS GIF Custom Simulator

EX)

and
https://discuss.eroscripts.com/t/conversation-heart-suggested-music/288486


OFS GIF Custom Simulator

This is a MultiFunPlayer, WebSocket-based GIF Custom Simulator


HOW TO

download file

Unzip it


  • Connect MFP with OFS
  • In Output, add WebSocket +
  • Set the address to localhost and choose any port you like
    (Turn off all axes except “stroke / up–down”)

  • Click the clock icon in the top-right corner and select FixedUpdate,
    then set the Hz to the maximum
  • Choose any port number for WebSocket → When launching GIF Custom Simulator.exe, enter that same port

For the view.gif file:

view

  • The current view.gif is a 21-frame GIF that goes Up → Down (100 → 0) once
  • The file must be in the same folder as GIF Custom Simulator.exe for it to load properly

If you want to use your own GIF:

→ Create a 21-frame GIF that goes Up → Down (one full cycle),
rename it to view.gif, and replace the existing file.
You can edit or create the GIF easily using tools like ezgif.


P.S

It was developed in Rust

I built this quickly using GPT, so I didn’t add any extra features (like support for 11-frame GIFs, etc.). And I don’t really know much about coding…
When loading a script, the first 3–5 seconds may be slightly out of sync — same thing if you manually jump to a different section.
I’m not even fully sure how reliably it works yet.
The script likely places one frame per 5% interval, so sections that use fine adjustments like 98, 93, and so on might not display correctly.
The vibration sections with a 33ms interval seem to be working fine, though.
Please change the code as much as you want


Source Code

main.rs


use std::{
    fs::File,
    io::{self, BufReader, Write},
    net::TcpListener,
    sync::{
        Arc,
        atomic::{AtomicUsize, Ordering},
    },
    thread,
    time::Duration,
};

use anyhow::Result;
use image::codecs::gif::GifDecoder;
use image::AnimationDecoder;
use minifb::{Key, Window, WindowOptions};
use tungstenite::{accept, Message};

type SharedIndex = Arc<AtomicUsize>;

fn main() -> Result<()> {
    // ------------------------
    // 🔷 port input in console
    // ------------------------
    print!("WebSocket port is (ex: 8090) : ");
    io::stdout().flush().unwrap();

    let mut input = String::new();
    io::stdin().read_line(&mut input)?;
    let port: u16 = input.trim().parse().unwrap_or(8090);
    let ws_addr = format!("127.0.0.1:{port}");

    println!("[WS] select port = {port}");

    // ------------------------
    // GIF loading
    // ------------------------
    let gif_path = "view.gif";  // ⚡ read same Path in exe
    let max_frames = 21;

    let (width, height, frames) = load_gif_frames(gif_path, max_frames)?;
    println!("GIF loaded: {width}x{height}, {} frames", frames.len());

    // ------------------------
    // mapping generate
    // ------------------------
    let mapping = Arc::new(build_mapping(frames.len()));

    // WebSocket & rendering share state
    let latest_idx: SharedIndex = Arc::new(AtomicUsize::new(0));

    // ------------------------
    // WebSocket thread start
    // ------------------------
    {
        let idx_ws = Arc::clone(&latest_idx);
        let map_ws = Arc::clone(&mapping);

        thread::spawn(move || {
            if let Err(e) = run_ws_server_custom(idx_ws, map_ws, ws_addr) {
                eprintln!("[WS] error: {e:?}");
            }
        });
    }

    // ------------------------
    // screen rendering start
    // ------------------------
    run_window(width, height, frames, latest_idx)?;

    Ok(())
}

// -----------------------------------
// GIF read
// -----------------------------------
fn load_gif_frames(path: &str, max_frames: usize) -> Result<(usize, usize, Vec<Vec<u32>>)> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let decoder = GifDecoder::new(reader)?;
    let frames = decoder.into_frames();
    let frames = frames.collect_frames()?; 

    let mut out_frames = Vec::new();
    let mut width = 0;
    let mut height = 0;

    for frame in frames.into_iter().take(max_frames) {
        let buf = frame.buffer();
        width = buf.width() as usize;
        height = buf.height() as usize;

        let rgba = buf.as_raw();

        let mut rgb32 = vec![0u32; width * height];
        for i in 0..(width * height) {
            let r = rgba[i * 4] as u32;
            let g = rgba[i * 4 + 1] as u32;
            let b = rgba[i * 4 + 2] as u32;
            rgb32[i] = (r << 16) | (g << 8) | b;
        }
        out_frames.push(rgb32);
    }

    Ok((width, height, out_frames))
}

// -----------------------------
// mapping 0~10000 → frame idx
// -----------------------------
fn build_mapping(frame_len: usize) -> Vec<usize> {
    let mut map = Vec::with_capacity(10001);
    for x in 0..=10000 {
        let idx = (frame_len - 1) - ((x * frame_len) / 10000);
        map.push(idx);
    }
    map
}

// -----------------------------
// WS server
// -----------------------------
fn run_ws_server_custom(
    latest: SharedIndex,
    mapping: Arc<Vec<usize>>,
    addr: String,
) -> Result<()> {

    let server = TcpListener::bind(&addr)?;
    println!("[WS] listening on ws://{addr}");

    for stream in server.incoming() {
        let stream = stream?;
        let idx_ws = Arc::clone(&latest);
        let map_ws = Arc::clone(&mapping);

        thread::spawn(move || {
            if let Err(e) = handle_client(stream, idx_ws, map_ws) {
                eprintln!("[WS client] error: {e:?}");
            }
        });
    }
    Ok(())
}

fn handle_client(
    stream: std::net::TcpStream,
    latest: SharedIndex,
    mapping: Arc<Vec<usize>>,
) -> Result<()> {

    let mut ws = accept(stream)?;

    loop {
        let msg = ws.read_message()?;
        match msg {
            Message::Text(text) => {
                if text.len() >= 6 {
                    if let Some(v) = parse_4digits(&text[2..6]) {
                        let v = v as usize;
                        if v < mapping.len() {
                            let idx = mapping[v];
                            latest.store(idx, Ordering::Relaxed);
                        }
                    }
                }
            }
            Message::Close(_) => break,
            _ => {}
        }
    }

    Ok(())
}

// -----------------------------
// -----------------------------
fn parse_4digits(s: &str) -> Option<u16> {
    if s.len() != 4 {
        return None;
    }
    let b = s.as_bytes();
    let d0 = b[0].wrapping_sub(b'0') as u16;
    let d1 = b[1].wrapping_sub(b'0') as u16;
    let d2 = b[2].wrapping_sub(b'0') as u16;
    let d3 = b[3].wrapping_sub(b'0') as u16;

    if d0 > 9 || d1 > 9 || d2 > 9 || d3 > 9 {
        return None;
    }

    Some(d0 * 1000 + d1 * 100 + d2 * 10 + d3)
}

// -----------------------------
// -----------------------------
fn run_window(
    width: usize,
    height: usize,
    frames: Vec<Vec<u32>>,
    latest: SharedIndex,
) -> Result<()> {

    let mut window = Window::new(
        "GIF Custom Simulator (99DM, ananymous) ",
        width,
        height,
        WindowOptions::default(),
    )?;

    // CPU
    window.limit_update_rate(Some(Duration::from_micros(200)));

    while window.is_open() && !window.is_key_down(Key::Escape) {
        let idx = latest.load(Ordering::Relaxed).min(frames.len() - 1);
        window.update_with_buffer(&frames[idx], width, height)?;
    }

    Ok(())
}
13 Likes

What’s the PMV being played on the left used in the example video?

1 Like

this
https://discuss.eroscripts.com/t/mrfanaticxv-fast-and-hard-houshou-marine-hmv-suggested/229187

1 Like

Thanks!

1 Like

I was actually playing around with the source code after I saw this. I thought it’d be interesting to make a script where you provide a funscript file, it reads the positions, maps it to GIF frames, and makes a new video stitching/merging all the GIF frames together into a new video. I haven’t gotten it to look as good as your preview though. I think something like this could be useful for making quick custom PMVs that are mapped to an existing funscript (maybe one generated from PythonDancer or something). The Beat Banger game actually kind of does this, but I think a standalone program would be pretty cool.

1 Like

yeah~