McFabScript is a small domain-specific language built into McFabProxy for automating packet fabrication workflows. It is designed specifically for exploit research: sending sequences of packets, waiting for server responses, inspecting field values, and reacting conditionally — all at millisecond precision.
Scripts run server-side inside the proxy process, with direct access to the live session, the packet capture stream, and the registry. There is no need to round-trip through the browser.
- Overview
- Running scripts
- Language basics
- Variables
- Operators
- Control flow
- Functions
- Built-in functions
- The
packetobject - The
timed_outvariable - Registry integration
- Examples
- Limitations
McFabScript is:
- C-style — curly braces, semicolons, familiar operators
- Dynamically typed — one
varkeyword, values adapt at runtime - Async-capable —
sleep()andwait_for()yield to the Tokio runtime without blocking the proxy relay - Registry-aware —
send()looks up packets by name from the live registry;wait_for()matches incoming packets by name and decodes their fields
Scripts run in a dedicated background task. The proxy continues relaying packets normally while a script executes. Sending a packet from a script and the proxy forwarding a client packet can happen concurrently.
Open the Script tab. Write or paste your script in the editor pane.
Click ▶ Run to execute. The button turns red (■ Stop) while a
script is running. Output from log() and errors appear in the output
panel below. Click ■ Stop to abort a running script at the next
sleep() or wait_for() call.
# Run a script
curl -X POST http://127.0.0.1:25568/api/script/run \
-H 'Content-Type: application/json' \
-d '{"code": "log(\"hello\"); sleep(500); log(\"world\");"}'
# Stop a running script
curl -X POST http://127.0.0.1:25568/api/script/stop
# Check status
curl http://127.0.0.1:25568/api/script/statusOnly one script runs at a time. Submitting a new script while one is running stops the old one first.
Every statement ends with a semicolon. Blocks use curly braces. Line
comments start with //.
// This is a comment
var x = 42; // variable declaration
x = x + 1; // assignment (x must already be declared)
send(swing_arm, hand=0); // call a built-in
Whitespace is ignored. Use whatever indentation you prefer — the language does not enforce it.
var, if, else, repeat, times, function, void, raw,
true, false
raw is reserved as a sentinel for send(raw, ...) and cannot be used
as a packet name in the registry.
var name = expression;
Variables are dynamically typed. There is one type keyword: var. Values
can be numbers, strings, or booleans — the type changes freely.
var x = 5;
var msg = "hello";
var flag = true;
var result = x * 2 + 1;
Assignment (to an already-declared variable):
x = x + 1;
msg = "world";
Assigning to a name that was never declared with var is a runtime error.
Inside send() and wait_for() calls, unquoted packet names are
resolved as string literals if the name does not match a declared
variable. This enables the ergonomic syntax send(swing_arm, hand=0)
without quoting swing_arm. If you have a variable named the same as a
packet, quote the packet name or rename the variable.
send(swing_arm, hand=0); // "swing_arm" resolved as string
var swing_arm = "off_hand_swing";
send(swing_arm, hand=1); // variable takes precedence: name = "off_hand_swing"
send("swing_arm", hand=0); // explicit string — always the packet name
| Operator | Meaning |
|---|---|
+ |
Addition (numbers) or string concatenation (when either side is a string) |
- |
Subtraction |
* |
Multiplication |
/ |
Division (runtime error on divide-by-zero) |
% |
Remainder |
| Operator | Meaning |
|---|---|
== |
Equal (null==null, bool==bool, number≈number, string==string) |
!= |
Not equal |
< |
Less than |
> |
Greater than |
<= |
Less than or equal |
>= |
Greater than or equal |
| Operator | Meaning |
|---|---|
&& |
Logical AND (short-circuits) |
|| |
Logical OR (short-circuits) |
! |
Logical NOT |
| Operator | Meaning |
|---|---|
& |
Bitwise AND |
| |
Bitwise OR |
^ |
Bitwise XOR |
~ |
Bitwise NOT (unary) |
<< |
Left shift |
>> |
Right shift (arithmetic) |
Bitwise operators cast both operands to 64-bit integers before operating.
~ ! - (unary)
* / %
+ -
<< >>
&
^
|
< > <= >=
== !=
&&
||
Use parentheses to override: (a + b) * c.
if (condition) {
// ...
} else if (other_condition) {
// ...
} else {
// ...
}
var hp = 3;
if (hp <= 0) {
log("dead");
exit();
} else if (hp < 10) {
log("low health: " + hp);
} else {
log("healthy");
}
repeat <count> times {
// body
}
count can be a variable or expression. The loop runs exactly that many
times (truncated to a non-negative integer).
repeat 5 times {
send(swing_arm, hand=0);
sleep(100);
}
var n = 10;
repeat n times {
send(use_item, hand=0);
sleep(50);
}
There is no break, continue, or while. Use exit() to stop
execution entirely, or structure your logic with repeat + if.
function name(param1, param2) {
// body
}
function no_args(void) {
// body
}
Functions have no return value. They execute for their side effects (sending packets, logging, sleeping). Functions can call other functions and built-ins. Recursion is supported but there is no stack depth guard — keep it shallow.
function spam(count, delay_ms) {
repeat count times {
send(swing_arm, hand=0);
sleep(delay_ms);
}
}
function greet(name) {
log("hello " + name);
}
greet("world");
spam(5, 100);
Function definitions can appear anywhere in the script. They are collected before execution begins, so you can call a function before its definition.
Parameters: positional only. Extra arguments are ignored; missing
arguments default to null.
Send a named packet from the registry using the current protocol state.
send(swing_arm, hand=0);
send(chat_message, message="hello server");
send(player_action, action=1, location=0, face=0);
Field values are passed as named arguments matching the registry field names. Missing fields produce a runtime error. Extra fields are ignored.
The current protocol state (play, login, etc.) determines which registry section is searched. If the packet name is not found in the current state, a runtime error is raised.
Send a raw packet by numeric ID and hex payload, bypassing the registry.
send(raw, 0x1b, "01"); // swing_arm with hand=1 (off hand)
send(raw, 0x2c, "00000000"); // raw keep-alive response
id is a number (decimal or 0x hex). hex is a string of hex bytes
without spaces. An empty string sends a packet with no payload.
Pause execution for the given number of milliseconds. The proxy continues relaying packets during the pause.
sleep(500); // 500 ms
sleep(50); // 50 ms
All time values in McFabScript are in milliseconds.
Wait for a packet with the given name to pass through the proxy (either direction). Blocks until a matching packet is captured or the timeout elapses.
wait_for(entity_status, timeout=3000);
if (timed_out) {
log("no entity_status received within 3 seconds");
exit();
}
log("entity id: " + packet.entity_id);
On success:
timed_outis set tofalsepacket.field_namegives access to the decoded fields of the captured packet
On timeout:
timed_outis set totruepacket.*fields from the previouswait_forremain unchanged
The packet name must exist in the registry for the relevant protocol state. See Registry integration for how to add S2C packet definitions.
timeout defaults to 5000 ms if omitted.
Write a message to the Script output panel. Any value (number, string, bool) is accepted and converted to a string.
log("hello");
log(42);
log(x + " items processed");
log(packet.player_id);
Log entries appear in the output panel in real time and are tagged with a
timestamp and level ([info], [warn], [error]).
Stop the script immediately. No further statements execute.
if (timed_out) {
log("aborting: server did not respond");
exit();
}
exit() always succeeds — it is not an error condition.
After a successful wait_for() call, the captured packet's fields are
accessible via packet.field_name:
wait_for(entity_status, timeout=2000);
if (!timed_out) {
log("entity id: " + packet.entity_id);
log("status: " + packet.entity_status);
}
Fields are decoded from the raw wire bytes using the registry definition. The decoded value type depends on the field type:
| Registry field type | McFabScript value |
|---|---|
bool |
Boolean |
i8, u8, i16, u16, i32, u32, i64 |
Number |
varint |
Number |
f32, f64 |
Number |
string |
String |
uuid |
String (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) |
hexbytes |
String (hex-encoded bytes) |
packet is populated by the last wait_for() call in the current script
execution. Accessing a field that does not exist in the packet's registry
definition raises a runtime error.
timed_out is a built-in boolean that is automatically set by wait_for().
You do not declare it — it is always available.
wait_for(chat, timeout=5000);
if (timed_out) {
log("no chat message for 5 seconds");
} else {
log("got chat: " + packet.message);
}
Always check timed_out after wait_for(). If you call wait_for() and
the timeout fires, accessing packet.* fields returns values from the
previous successful capture (or raises an error if there was none).
McFabScript's send() and wait_for() both look up packets by name from
the live registry (registry/774.json). The registry is shared with the
rest of the proxy — changes made through the Registry Wizard or
/api/registry/add are immediately visible to running scripts.
These are the packets you send to the server. The registry ships with
handshake, login, and configuration entries. You add play entries
by watching the packet stream and using the Registry Wizard.
wait_for() captures packets in any direction. To wait for a server
response by name, you need a registry entry for the S2C packet. Add it
like any other entry — the registry does not distinguish direction at the
storage level.
Workflow:
- Open the Monitor tab while performing a game action.
- Find the S2C packet you want to react to (direction: s2c).
- Click Create Registry Entry, name it, define its fields.
- Use
wait_for(your_packet_name, timeout=2000)in your script.
// Swing arm 20 times with 50 ms gaps
repeat 20 times {
send(swing_arm, hand=0);
sleep(50);
}
log("done");
// Send a use-item packet, wait for the server acknowledgement
send(use_item, hand=0);
wait_for(block_update, timeout=1000);
if (timed_out) {
log("no block_update after 1 second");
exit();
}
log("block pos: " + packet.location);
log("block state: " + packet.block_state_id);
var attempts = 0;
var success = false;
repeat 5 times {
send(interact_entity, entity_id=1, type=0, hand=0, sneaking=0);
sleep(100);
wait_for(entity_status, timeout=500);
if (!timed_out) {
log("got response on attempt " + attempts);
success = true;
}
attempts = attempts + 1;
}
if (!success) {
log("entity never responded after " + attempts + " attempts");
}
// Measure round-trip time for a packet exchange
var start = 0;
// Note: there is no clock function yet — use sleep as a timing anchor
send(player_position, x=0.0, y=64.0, z=0.0, on_ground=1);
wait_for(chunk_data, timeout=5000);
if (timed_out) {
log("server did not send chunk data");
} else {
log("chunk received — position: " + packet.chunk_x + "," + packet.chunk_z);
}
function try_interact(entity_id, delay_ms) {
send(interact_entity, entity_id=entity_id, type=0, hand=0, sneaking=0);
sleep(delay_ms);
wait_for(entity_status, timeout=500);
if (timed_out) {
log("entity " + entity_id + " did not respond");
} else {
log("entity " + entity_id + " status: " + packet.entity_status);
}
}
try_interact(1, 100);
try_interact(2, 100);
try_interact(3, 100);
// Check a bitmask field from a server packet
wait_for(player_abilities, timeout=2000);
if (!timed_out) {
var flags = packet.flags;
var is_invulnerable = (flags & 0x01) != 0;
var is_flying = (flags & 0x02) != 0;
var can_fly = (flags & 0x04) != 0;
var instant_build = (flags & 0x08) != 0;
log("invulnerable: " + is_invulnerable);
log("flying: " + is_flying);
log("can fly: " + can_fly);
log("instant build: " + instant_build);
}
Offline-mode only. Scripts send packets through the same session as the regular proxy relay. They share the same restriction: offline-mode servers only (no encryption).
No return values. Functions do not return values. Use a global variable to communicate results out of a function.
No while or break. Use repeat N times with exit() for early
termination.
Abort only at await points. Clicking ■ Stop cancels the script
at the next sleep() or wait_for() call. A tight loop with no
sleep() or wait_for() cannot be interrupted until it yields. Always
include sleep() inside hot loops.
No compound assignment. x += 1 is not supported. Write x = x + 1.
No nested functions. Functions are defined at the top level only. Inner function definitions inside blocks are not supported.
Field decoding requires registry entries. wait_for() can only match
and decode packets that have a registry definition. Packets without a
definition pass through unmatched.
Packet matching is name+state only. wait_for() matches the first
packet whose name appears in the registry for the packet's protocol state,
regardless of direction. If you have both C2S and S2C packets with the
same name in the same state, the first one captured wins.
Single script at a time. Submitting a new script stops any running script first.