SplitScript reference / SplitScript for Rust authors

SplitScript for Rust authors

SplitScript shares Rust's expression-oriented control flow, fixed-width numbers, as casts, exhaustive match, postfix ?, and strong inference. It removes ownership syntax and exposes autosplitter lifecycle and process attachment directly, targeting WebAssembly GC rather than native code.

Keep these important spelling and semantic changes in mind:

  • Rust () becomes SplitScript None.
  • A Rust function tail expression becomes an explicit SplitScript return.
  • Rust impl Display becomes an inferred default or a Type.toString method.

Bindings and inferred capabilities

Use let without mut; ordinary bindings are mutable, while values such as old snapshots and iteration elements are read-only by their role. Function parameters and returns may be inferred from all uses. Generic behavior is reported as capability bounds such as Numeric, Display, or MemoryReadable, which play a trait-like role but are currently declared by the standard library rather than user programs.

Irrefutable binding patterns destructure values in initialized let declarations, function and closure parameters, and runtime for bindings. This is close to Rust's irrefutable binding sites, including the fact that a variant pattern becomes irrefutable when every alternative carries Never. SplitScript has no let ... else; use is or match when another shape can actually occur. A type annotation after the pattern describes the one incoming value, not each name introduced by the pattern.

As in Rust, a concrete expected struct type permits { x, y } without writing the struct name. This is contextual nominal shorthand, not structural typing; an otherwise unconstrained parameter still needs an annotation or an explicit Position { x, y } pattern.

User code does not need an impl Display for Type block. Structs and enums derive a multiline Display representation automatically. Define fn Type.toString() -> String only to override it; the result type may be inferred. The derived or custom implementation powers interpolation, as String, print, and setVariable.

struct Position {
    x: i32,
    y: i32,
}
fn Position.toString() { return `({self.x}, {self.y})` }
print(Position { x: 3, y: 5 })
fn greater(left, right) {
    return left > right
}

print(greater(7u32, 3u32))

Arrays use [T] and [T; N], structs are GC product types, and enums support payload variants. There are no lifetimes, moves, borrows, or explicit memory management in source.

Blocks yield values, functions use return

Nested blocks are expression-oriented much like Rust. Their final expression is their value, so an if branch, match arm, fallback, or argument can perform local setup before producing a result:

fn levelLabel(isBoss: bool) -> String {
    let label = if isBoss {
        let kind = "Boss"
        `{kind} level`
    } else {
        "Level"
    }
    return label
}

Unlike Rust, a function body does not implicitly return its tail expression. Write return explicitly. This also applies to methods and lifecycle actions; falling through an action uses that action's documented default. The compiler recognizes the common Rust spelling, explains the distinction, and offers to insert return. A semicolon on a value block's final expression is accepted but warned about and removed by the formatter; it never silently changes the block to None.

Loop expressions keep Rust's useful part

loop is expression-valued as in Rust. With no reachable break it has type Never; break value determines its result, and a bare break produces None. while and runtime for remain statement loops and reject value-carrying breaks.

fn choose(flag: bool) -> i32 {
    return loop {
        if flag { break 7 }
        break -1
    }
}

The important difference is the explicit return: even when the final expression is a loop, a SplitScript function does not return it implicitly.

Generators create ordinary iterators

SplitScript has synchronous generator functions even though stable Rust does not have equivalent yield syntax. A call is lazy, and only next() advances the body:

fn values(end: u32) -> iterator u32 {
    let value = 0u32
    while value < end {
        yield value
        value += 1
    }
}

for value in values(3) {
    print(value)
}

Unlike Rust's impl Iterator<Item = T>, the public iterator T spelling is an erased cursor type. Arrays, ranges, sets, maps, adapters, and generators all return it, so an ordinary function can forward any one of them with return. Only an iterator T body that actually contains yield becomes a generator.

The cursor implements Iterator and Iterable, uses Item and End instead of Option<T>, and aliases its position when copied. Generators are synchronous: await, retry, fallible propagation, and value-returning return require a different protocol and are rejected.

None is the unit type

None is both the language's zero-sized unit value and the absent side of T?. Functions that only perform effects infer None; void and Rust's () are not source syntax. Plain T values promote into T? and T!, while Some and Ok are used only to distinguish patterns.

fn parseCount(text: String) -> u32! {
    return text.parse()
}

fn showCount(text: String) -> None {
    let count = parseCount(text) else 0u32
    print(count)
}

showCount("4")

Postfix ? propagates to the nearest state-field boundary or T! function. Use else fallback for local recovery and match for explicit Ok(value)/Err(message) handling.

Async values and cancellation

An asynchronous value has type async T. Named functions write that as -> async T; lifecycle blocks infer suspension from await. Unlike an executor-agnostic Rust future, attachment-owned work is automatically cancelled when its process closes.

state "game.exe" {}

fn findImage() -> async Module {
    return await process.module("GameAssembly.dll")
}

onAttach {
    let image = await findImage()
    print(image.address)
}

Use retry expression for synchronous fallible work that should be evaluated again on later ticks until it succeeds. Unlike Rust's function-scoped ?, retry establishes a local failure boundary, so a block can describe one complete transaction:

onAttach {
    let health = retry {
        let player = process.read<address>(0x1000)?
        process.read<i32>(player)?
    }
    print(health)
}

The braces are an ordinary value block, not special retry syntax. ?, a final Err(...), or throw ends the current attempt and starts the complete operand again on the next attached update. return, break, and continue keep their lexical targets. One attempt must be synchronous and bounded: it cannot evaluate await or another retry. Calling an async function is allowed because it only constructs an async T; awaiting that value is not. Use ordinary await when one asynchronous operation already owns its polling policy.

Autosplitter domains

state owns attachment and memory polling. Its accepted values become the transactional old and current snapshots. onAttach, onStateReady, whileAttached, and onDetach describe process-lifetime phases. start, split, reset, isLoading, and gameTime communicate timer decisions.

state "game.exe" {
    level: u32 at 0x1000
}

split {
    return old.level != current.level
}

Process reads are fallible and require a concrete MemoryReadable layout. Prefer state pointer paths for values polled every tick and direct reads or signature scans for attachment-time discovery. Settings are a typed declaration DSL, not a map assembled at runtime.

Next step

Open Getting started from the documentation index and build its first autosplitter workflow. Use Search Documentation for exact capabilities, language forms, and migration concepts rather than assuming a Rust trait or runtime API has a one-to-one equivalent.