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 SplitScriptNone. - A Rust function tail expression becomes an explicit SplitScript
return. - Rust
impl Displaybecomes an inferred default or aType.toStringmethod.
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.
User code does not need an impl Display for Type block. Records 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.
record 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], records 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.
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.