-
Notifications
You must be signed in to change notification settings - Fork 0
MicroPython Programming Guide
中文 · English
For players who already know a bit of Python / MicroPython. This skips the language basics and covers just two things: how this differs from the Python you already know, and how to drive the components in the game (screens, keyboards, machinery, AE2 networks, and so on).
What runs in the game is a MicroPython implemented purely in Java. The bytecode format is compatible with standard MicroPython and the core language is almost the same, but it runs inside a controlled sandbox, designed specifically to be "saveable and rate-limited per tick".
The parts you can treat as ordinary Python: integers (including arbitrary-precision big
integers), floats, the full set of string methods, list/dict/set/tuple, functions/closures/
classes/inheritance/decorators, generators (yield/send/yield from), exceptions,
with, comprehensions, f-strings, exec/eval, isinstance/sorted/enumerate/
zip/map/filter, and so on.
Three key differences to keep in mind up front:
-
There is no real operating system layer: files, screens, keyboards and machinery are
not part of the Python standard library; you reach game components through the
injected global objects
componentandcomputer(see section 3). - Rate-limited per tick: your script can only execute a fixed number of instructions per game tick (see section 5). An infinite loop will not freeze the server, but spinning idly will get you throttled. You need to actively yield.
- Saveable at any moment: the entire running state can be frozen into the save file and resumes in place after loading a save. This means you don't have to worry about losing progress when you quit the game, but it also means some things (see section 6) behave differently from ordinary Python.
-
Both the
importstatement and dynamic imports via__import__("x")work. Dynamic import is a first-class function: you can store it in a variable, use it in a loop, and select a module by variable name:m = __import__(name). -
exec/evalare available. There are also the non-standardexec_sandbox/eval_sandbox: reads fall through to the outermost globals, writes stay local (the shell's per-command isolation is implemented on top of this). Plainexechas the standard isolation semantics. -
The full set of set operations: difference
a - b, intersectiona & b, uniona | b, symmetric differencea ^ bare all supported (they return a new set and do not modify the original). -
Generator expressions can be passed straight to built-in functions:
",".join(str(x) for x in xs),sum(x*2 for x in xs),sorted(x for x in xs),set(...)all work; no need to convert to a list first. -
boolis not a subclass ofint,rangecompares by identity, and dict/set are insertion-ordered. These match MicroPython (not standard CPython). -
Slicing is fully available: reading (including strides,
xs[::2]/xs[::-1]), assignment (xs[a:b] = [...], length may change; strided assignment requires equal lengths), and deletion (del xs[a:b]);rangecan be sliced too (returning a new range). -
propertyis complete:@x.setter/@x.deleter/property(fget, fset, fdel)are all supported. -
Function attributes are readable and writable:
f.attr = xandf.__name__both work (functools.wraps relies on this). -
bytes.joinis available:b",".join([b"a", b"bc"])(items may be bytes or bytearray). -
list.sort(key=..., reverse=...)supports keyword arguments just likesorted; sequence repetition works in both directions ([0] * 3and3 * [0]);x in range(...)is an O(1) arithmetic test, andx in b"abc"supports both int membership and substrings. -
An uncaught
SystemExitat the top level is a clean exit (the same as standard Python): it is not treated as an error and prints no traceback; inside a thread it only ends that thread (_thread.exit()is implemented exactly this way).
-
frozenset,memoryview(no need to implement them). - Standard
open/input(file access goes throughosorcomponent, see below). - Networking, preemptive threads, hyperbolic/special math functions.
-
callable(x),id(x),hash(x),__import__(name, ...). -
bytearray: a mutable byte sequence backed by a Java array. Constructors:bytearray()/bytearray(n)/bytearray(b"..")/bytearray([65,66])/bytearray(i for i in range(4))/bytearray("AB","utf-8"); mutable indexingba[0]=90, slicing,del ba[i],append/extend/pop/clear/copy/decode/hex;==can compare across types withbytes.
_thread binascii cmath collections deflate functools gc hashlib heapq
io jasyncio json machine math micropython os pickle random
re struct sys threading time traceback
Most modules behave the same as standard MicroPython. re is more capable than the
official tiny-re (it supports {n,m} quantifiers and groups()/span()/start()/
end()). functools provides reduce/partial/lru_cache (with cache_info/
cache_clear)/wraps/cmp_to_key. os has a complete filesystem under mpyos (see
the "Working with files" section).
_thread is MicroPython's low-level thread interface (start_new_thread /
allocate_lock / LockType / get_ident / exit / stack_size), which makes it easy to
bring existing MicroPython code over. It is not a separate mechanism from threading —
_thread is just a thin layer on top of threading, both share the same thread pool, and
they can be mixed. The behaviour matches upstream: get_ident() returns each thread's own
real ident; an uncaught exception in a thread prints the official-format
Unhandled exception in thread started by ... plus a traceback, takes down only that
thread, and leaves the others alone; stack_size() returns the old value per the official
contract (the value is recorded but has no actual effect — threads here do not have
separate stacks). Of the official tests/thread suite, 32 of 33 tests pass (the remaining
one requires a real stdin, which upstream likewise skips on ports that don't support it).
machine only provides the parts that can be given real semantics: disable_irq() /
enable_irq(state). In this VM the "interrupt" is the scheduler itself, so the semantics
are exact — while IRQs are disabled, only the thread/main program that disabled them runs,
everything else gives way, and the state token can be nested. You can use it to build a
hard critical section across threads (heavier than a Lock; use it with care: while
interrupts are off, even coroutines and other threads' timers stop advancing). Anything
that cannot be given real semantics — pins, timers, reset and the like — is simply absent
rather than faked.
micropython provides schedule(func, arg), which queues a callback to run at the
next scheduling point rather than immediately — handy when you want to hand work off to the
event loop from anywhere. As upstream, the queue holds at most 8 entries; when it is
full it raises RuntimeError: schedule queue full (which gives you backpressure when
production outruns consumption). heap_lock / heap_unlock / mem_info exist but do
nothing: this VM runs on Java, and there is no interpreter heap to lock.
The following two differ substantively from the standard library modules you know, so they get their own sections.
This is the project's own coroutine runtime. It is not CPython's asyncio, and it is
not MicroPython's uasyncio either. Most of the API names will look familiar, but
scheduling is driven by the host's per-tick ops budget, and there are several key
differences:
-
The import name is literally
jasyncio(notasyncio):import jasyncio. -
What it has:
run(coro),create_task(coro),gather(*aws, return_exceptions=False),wait(aws, timeout=, return_when=),wait_for(aw, seconds)/wait_for_ms,sleep(s)/sleep_ms(ms),to_thread(func, ...),current_task(),Future,Task,Event,Lock,Loop,get_event_loop()/new_event_loop()/set_event_loop(),CancelledError/InvalidStateError/TimeoutError. -
create_taskgives you aTaskobject: you can callt.done()to see whether it has finished,t.cancel()to cancel it,t.cancelled()to see whether it was cancelled, andawait tto get its return value. Cancellation is cooperative: once the flag is set,CancelledErroris raised at the line where the coroutine next suspends, so you can usetry / except CancelledErrorto clean up. -
Scheduling is cooperative round-robin: every
run()has its own private coroutine pool (tasks created bycreate_taskjoin the pool of whoever called it; callingcreate_taskdirectly in the main program or in a thread puts the task into a shared default pool). When a coroutine's turn comes within a pool, it runs until itawaits (or finishes) before the next one gets a turn; to the VM, the whole pool is one scheduling unit. There is no preemption — a coroutine stuck in an infinite loop will not be forcibly interrupted, but it will burn through the budget its pool was allotted for that tick. -
sleep_ms(ms)uses real wall-clock time (System.currentTimeMillis), but only advances while the VM is alive and being driven: time that passes while the world sits in a save file does not count — on loading a save, each timer's baseline is reset to "how much is left". So asleep(5)across a save-and-quit will not wake up immediately just because you were away for an hour. -
run()runs the top-level coroutine to completion and returns its result;gatherruns a group concurrently and collects the results in order;to_threadschedules a synchronous blocking function as an independent (thread-style) task and returns an awaitable. -
You can have several
run()s going: one in the main program and one in each thread is fine — eachrun()is an independent event loop (with an independent coroutine pool), and they do not interfere with each other. Callingrun()from inside a coroutine is still an error (RuntimeError), just as in asyncio. -
When
run()returns, any tasks in that loop that have not finished are cancelled (the same as CPython'sasyncio.run): each cancelled task receives aCancelledErrorat the point where it was suspended,except/finallyget a chance to clean up, and only then doesrun()return. If you want a task to outliverun(), hand it to a thread. -
Threads and coroutine pools live in the same VM task pool, but they are not equals:
each
threading.Thread/to_threadthread is an independent task in the pool and interleaves freely at budget boundaries; a group of coroutines (onerun, or the default pool) collectively occupies a single slot. The way you write the code differs too — see the next section. -
Coroutines in the same pool never see each other's intermediate state: switching only
happens at
await, and the whole group of coroutines is one scheduling unit — even if one coroutine in the group is interrupted by the budget between twoawaits, or goes off to make a component call, what is paused is the entire group, and the other coroutines in it still cannot slip in. This is a structural guarantee, not a matter of luck. (Threads have no such guarantee; a thread may see someone else's work half-done at any moment — that is exactly what thread semantics mean. The same goes for two coroutine groups belonging to differentrun()s: to each other they behave like threads.)
From a scheduling standpoint the two are the same thing; the difference is where you can suspend from:
threading threads are "stackful": the entire call stack is saved, so you can suspend
inside an ordinary function at any nesting depth. This works:
import threading, time
def deep():
time.sleep(0.5) # sleep right inside a plain function: the whole thread suspends, other threads keep running
def middle(): deep()
def worker(): middle()
threading.Thread(target=worker).start()Three levels of ordinary functions, worker → middle → deep; when deep sleeps, the
whole stack is preserved as-is, and when its turn comes round it continues from that line.
You do not have to change a function's signature just because it "might suspend".
jasyncio coroutines are "stackless": only an await inside an async def is a
suspension point. The ability to suspend does not propagate through ordinary functions —
if you want deeply nested code to suspend, every layer between it and the coroutine must be
an async def, and every layer must await. This is what is usually called "function
colouring":
async def deep(): # every layer must be async
await jasyncio.sleep(0.5)
async def middle(): await deep() # every layer must await
async def worker(): await middle()Which to pick:
- To put existing synchronous code (library functions, business logic with layers of
calls) into the background — use
threading; no need to rework the call chain. - To write an explicit concurrency flow, where you want "where it can suspend" to be
visible at a glance in the code — use
jasyncio;awaitis that marker. - The two can be mixed: a thread can start its own
run(), and a coroutine can useto_thread.
A small trap: if you write
awaitinside an ordinary function, the MicroPython compiler does not report an error (CPython raisesSyntaxError); it quietly turns that function into a generator, so calling it just gives you a generator object and the code inside never runs. So don't count on the compiler to catch this for you — remember that "awaitis only valid insideasync def".
A simple case:
import jasyncio
async def worker(n):
await jasyncio.sleep(0.5)
return n * 2
async def main():
results = await jasyncio.gather(worker(1), worker(2), worker(3))
print(results) # [2, 4, 6]
jasyncio.run(main())Standard MicroPython has no pickle at all; this one is provided additionally by this
project. But note:
- Its byte format is private (underneath it is a Java binary writer, not CPython's pickle opcode stream; the protocol version is deliberately set to the invalid value -1 so that CPython cannot misread it). So what it dumps cannot be fed to real CPython pickle, and vice versa — it is only self-consistent inside this VM; don't use it to exchange data with external programs.
-
What can be pickled:
None,bool,int,float,complex,str,bytes,tuple,list,dict,set,range, and namedtuple. Shared and circular references are correctly restored via back-references. - What cannot be pickled: functions, classes, generators. To save "live execution state", use a VM snapshot (the save file), not pickle.
- The API is just
dumps(obj)/loads(data).
import pickle
blob = pickle.dumps({"hp": 20, "pos": (1, 2, 3)})
data = pickle.loads(blob)This is the biggest difference from ordinary Python, and it is what you actually want to do.
There are two injected global objects, usable directly without importing: component and
computer.
# 1) List the addresses of components of a given type
for addr in component.list("filesystem"):
print(addr)
# 2) "Primary component" shorthand: component.<type> gives you a proxy for the first component of that type
gpu = component.gpu
gpu.set(1, 1, "Hello") # call its methods directly
w, h = gpu.getResolution()
# 3) Get a proxy by address
disk = component.proxy("hdd-1") # or any address from component.list
disk.makeDirectory("/data")
# 4) Direct invoke(address, method name, args...)
component.invoke("hdd-1", "exists", "/init.py")
# 5) Exploring: which methods does a component have
component.methods("hdd-1") # list of method names
component.doc("hdd-1", "read") # docstring of a given methodArguments are just ordinary Python values: numbers, strings, booleans. For parameters that need a Lua table, pass a dict or list directly — it is converted to a Lua table automatically:
me = component.me_interface
items = me.getItemsInNetwork({"name": "minecraft:iron_ingot"}) # dict = Lua tableReturn values: mostly ordinary values/lists/dicts. If a method returns an iterable OC
"userdata" (AE2's getItemsInNetwork, for example), you get a wrapper object, and you can
just for-iterate over it:
for stack in items: # lazy iteration: only one item is converted to a Python object at a time
print(stack)This is lazy — each pass through the loop converts and processes exactly one element,
rather than pulling the whole network into memory at once. So even if the AE2 network holds
thousands of item types, peak memory is a single item, and you can break out early at any
time (elements that are never consumed are never converted).
If you really do want a complete Python list (to call len(), sort, or iterate multiple
times), use .to_list() to pull everything in one go (use with care on large networks: it
converts every item into a Python object and takes memory); there is also .count() if you
just want to ask for the total first, and .getAll() to get the raw table.
n = items.count() # check the size first
all_items = items.to_list() # pull everything at once (use with care on large networks)computer.uptime() # seconds since boot (use this for timeouts, not time; see below)
computer.address() # this machine's address
computer.beep(1000, 0.2) # make a beep
computer.pullSignal(1) # wait for a signal, blocking at most 1 second (keyboard/timers etc. are all signals)
computer.pushSignal("my_event", 1, 2)
computer.shutdown(True) # True = reboot
computer.ops() # how much instruction budget is left this tick (see section 5)mpyos fits the standard os module with a filesystem API; just import os and use it — you
never have to touch the low-level component.filesystem. Relative paths are resolved against
os's own working directory, and /mnt/<disk name>/... is routed automatically to other
mounted disks.
import os
os.getcwd() # current working directory
os.chdir("/data") # change directory
os.listdir("/") # directory contents (bare names, no slashes)
os.mkdir("/data") # create a directory (OSError if it already exists)
os.makedirs("/a/b/c", exist_ok=True) # create each level
os.rmdir("/data") # remove an [empty] directory
os.remove("/data/x.txt") # remove a [file] (use rmdir for directories)
os.rename("/a.txt", "/b.txt") # rename/move
st = os.stat("/data/x.txt") # (mode, ..., size, atime, mtime, ctime)
# mode: 0x4000 directory / 0x8000 file; times are in seconds
os.statvfs("/") # space info (1-byte blocks: total/free/...)
# os.path is here too
os.path.join("/data", "logs", "a.log") # POSIX semantics: a later absolute segment resets the path
os.path.exists(p) / isdir(p) / isfile(p) / getsize(p)
os.path.dirname(p) / basename(p) / split(p) / splitext(p) / abspath(p)Two deliberate tightenings that match CPython: os.remove only removes files, never
directories; os.rmdir only removes empty directories. This blocks OC's low-level remove
from deleting recursively — recursive deletion is the shell rm's job, not os's.
os.urandom / os.uname are still the originals (from the frozen module) and have not been
overridden.
Install OC's internet card and mpyos can go online. All three libraries are bundled by
default — they will import fine even with no card installed; it is only at the moment you
actually send a request that OSError("no internet card installed") is raised. So a script
can put import requests at the top of the file as usual, and use try/except to determine
whether a card is present.
import requests
r = requests.get("https://example.com/api", params={"q": "苦力怕"})
print(r.status_code, r.reason, r.ok) # 200 OK True
print(r.headers.get("content-type")) # header names are case-insensitive
print(r.text) # decoded per charset; r.content is bytes
data = r.json()
requests.post("https://h/x", json={"a": 1}) # sets Content-Type automatically
requests.post("https://h/x", data={"a": "1 2"}) # form-encoded
requests.get(url, headers={"X-Token": "..."}, timeout=10)
# Don't read a large file into memory all at once:
with requests.get(url, stream=True) as r:
for chunk in r.iter_content(4096):
...urllib is the standard library's version, where urlopen returns a file-like object:
from urllib.request import urlopen
from urllib.parse import urlencode, urlparse, urljoin, quote
import urllib.error
try:
with urlopen("https://example.com/") as f:
print(f.status, f.getheader("content-type"))
print(f.read(100))
except urllib.error.HTTPError as e:
print("HTTP", e.code, e.msg)urllib.parse is pure Python and touches no components, so you can use it to build URLs
even without a network card.
Raw TCP goes through socket (only SOCK_STREAM; OC's card does not support UDP):
import socket
s = socket.create_connection(("example.com", 80), timeout=5)
s.sendall(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
print(s.recv(1024))
s.close()A few OC limitations you must know about:
-
Non-2xx responses have no body. OC's underlying implementation discards the body of an
error response outright, so on a 404/500 you still get
r.status_code/r.reason/r.headers, butr.contentis alwaysb"". If you want an exception, callr.raise_for_status(). -
A timeout is mandatory.
requestsdefaults to 30 seconds (in-game time) rather than CPython's "wait forever" — otherwise one dead server could hang the computer indefinitely. While waiting, it yields per tick, and time during which the chunk is not loaded does not count. -
HTTP / TCP can be turned off by server configuration, in which case you get a
requests.ConnectionErroror anOSError, with a message from OC such as "http requests are unavailable". -
socket.send()may write only part of the data (just like a real socket), so you have to loop;sendall()already does the looping for you.
There is a ready-made wget on the command line:
wget https://example.com/data.json
wget https://example.com/x.bin /mnt/a1b/x.bin
OC's methods were originally designed for Lua and often return several values at once. The bridging layer's rules are:
| Method returns | What you get |
|---|---|
| Several values | A Python tuple, which you can unpack directly |
| Exactly one value | That value itself (not a 1-element tuple) |
| Nothing | None |
ch, fg, bg = gpu.get(1, 1) # 3 values → unpack
w, h = gpu.getResolution() # 2 values → unpack
addr = computer.address() # 1 value → it's just the string; don't write addr, = ...
#ch, fg = gpu.get(1, 1) # you can't discard values like in Lua ValueError: too many values to unpack
#ch, fg, bg, more = gpu.get(1, 1) # missing values are not padded with None like in Lua ValueError: not enough values to unpack
nothing = computer.beep(1000, 0.2) # this gives you NoneLua's failure convention: many methods return two values (None, "error message") on
failure, and only one on success. So to be strict you have to check first:
r = fs.open("/nope", "r")
if isinstance(r, tuple): # failure: (None, error message)
handle, err = r
else:
handle = r # success: a single handleThe other kind of failure: raising an exception. The above covers a method "returning a failure value normally"; separately, a component may report an error directly, in which case a standard Python exception is raised, classified by cause (matching how OpenComputers classifies the same kinds of errors), so you can catch it precisely:
| Cause of failure | Exception | Typical message |
|---|---|---|
| Bad argument | ValueError |
the component's message, defaulting to bad argument
|
| Index out of bounds | IndexError |
index out of bounds |
| No such method | AttributeError |
no such method |
| Operation not supported | NotImplementedError |
unsupported operation |
| File does not exist | OSError |
file not found |
| Permission denied | OSError |
access denied |
| Read/write failure | OSError |
the component's message, defaulting to i/o error
|
| Anything else | RuntimeError |
the component's message, defaulting to unknown error
|
try:
component.invoke(addr, "someMethod", 1, 2)
except OSError as e: # file / permission / IO
print("IO failed:", e)
except ValueError as e: # bad argument
print("bad argument:", e)In Lua, some of these two categories are "return nil, reason". Python has no such idiom;
the equivalent is exceptions — so we raise uniformly, but split by type, instead of
mashing everything into a single error.
Calling too often is not an error: when a component's per-tick call budget is exceeded, the system automatically defers the call to the next safe opportunity and retries it, and the script never notices (consistent with OpenOS).
If checking as above is too much trouble, or you are not sure how many values a method
returns, wrap it in the special built-in lua() — when unpacking, missing values are
filled in with None and extra ones are dropped, so a count mismatch never raises:
a, b, c = lua(gpu.get(1, 1)) # 3 values → fine
a, b, c = lua(computer.address()) # single value → a=address, b=c=None
a, b, c = lua(comp.noret()) # no return → all None
a, b = lua(gpu.get(1, 1)) # 3 values unpacked into 2 → the 3rd is droppedlua()'s leniency applies only to values it has wrapped; what lua returns is a special
tuple. Unpacking an ordinary tuple with the wrong count still raises.
The screen goes through the GPU component and the keyboard through signals. The one thing relevant to ordinary Python is that GPU methods often return several values at once (see the "Multiple return values from components" section); just unpack them directly.
gpu = component.gpu
gpu.setForeground(0xFFFFFF)
gpu.set(1, 1, "white text")
# Many GPU methods return several values at once; just unpack
w, h = gpu.getResolution()
ch, fg, bg = gpu.get(1, 1)Keyboard events arrive as signals. The key_down signal is a 4-field tuple:
sig = computer.pullSignal(5) # wait at most 5 seconds
if sig is not None:
name = sig[0]
if name == "key_down":
_, kb_addr, char, code = sig # 4-tuple: name, keyboard address, character code, scan code
if char == 9: # Tab
...If you are writing command scripts in MPYOS's shell, the system also injects the more
convenient tty / sh / args (tty.print(...), sh.fs.read_file(...), and args for
command-line arguments), so you don't have to touch the GPU yourself.
Your script only executes a fixed number of instructions per game tick (this budget is called ops, and by CPU tier it is 500 / 2000 / 16000 instructions per tick). Unused budget accumulates (up to 10 times the per-tick amount), so when idle you are encouraged to yield rather than spin — once you have banked enough, you can burst and run for a long time.
The total budget for a tick (this tick's amount plus what has been banked, capped at 10x) is divided inside the VM like this:
- When there are no runnable background tasks: the whole budget goes to the main program (your main script).
- When there are runnable background tasks: it is split evenly by seats — each of the n runnable tasks takes one seat, and the main program also takes only one seat (n+1 seats in total). The main program no longer has any privilege: with two busy threads running, your main script gets only about a third. Whatever a task does not use spills over to the main program to continue using; sleeping tasks do not occupy a seat.
-
What counts as a seat: each thread is one seat; a group of coroutines (one
run, or the default pool) is one seat collectively, and within the group the group's scheduler rotates among the coroutines. -
While
jasyncio.run(coro)is blocking: the main program is stuck waiting on that loop, so the entire budget goes into advancing the background tasks until it produces a result, which is then handed back to the main program to continue.
To check how much is left:
-
computer.ops()— the total remaining for this step of this tick (including the bank). -
computer.ops_share()— the remaining amount of the current share (the main program's share, or the share allotted to a given coroutine; with no async running the two are equal). - The command line has an
opscommand to look at it directly.
A bare while True: pass will burn ops continuously and be throttled hard. When you need
to wait for something, use computer.pullSignal(timeout) or time.sleep(...) — they yield
the rest of the tick, letting the unused ops accumulate in the bank so that you can run
longer next time.
import time
while True:
do_one_step()
time.sleep(0.05) # yield this tick; it doesn't really sleep the full time, it's a cooperative yieldUse computer.uptime() for timing; don't rely on the absolute value of time.time() —
the game clock and the real-world clock are not the same thing:
deadline = computer.uptime() + 3 # 3 seconds from now
while computer.uptime() < deadline:
time.sleep(0.05)Source code has to be compiled to .mpy before it can run. There are two compilers (the
built-in pure-Java one and the bundled mpy-cross), switchable under Mods → mpy4oc →
Config, so this choice does not affect how you write Python, only "who does the compiling".
The mpy processor has two execution models, corresponding to four items (CPU and APU each come in synchronous and asynchronous versions, in 3 tiers each):
- Synchronous CPU / APU: the VM runs entirely on the main server thread. The benefit is that every component call has zero latency (it executes directly, without waiting for the next tick), and that execution is guaranteed every tick — choose this for scripts that are timing-sensitive and have to do work every tick (real-time displays, control logic with precise timing). The cost is that all computation consumes main-thread tick time.
- Asynchronous CPU / APU: the VM runs on OC's compute thread and only returns to the main thread when it hits a non-direct component call. The benefit is that pure computation does not occupy the main thread, easing the load on server TPS; it suits scripts that "compute a lot, call components rarely, and are not strict about timing". The cost is that a non-direct call has a round-trip latency of up to one tick (the same as vanilla Lua: each tick digests a limited number of non-direct calls, more at higher tiers).
The Python code for the two is exactly the same; the ops budget, the bank, and yielding
via pullSignal/sleep all apply equally — the only difference is which CPU item is
installed.
You do not have to do anything for the asynchronous case: which component methods must
run back on the main thread is determined automatically by the system (it checks the method's
direct flag on each call), and whether you use component.invoke,
component.proxy(addr).method(), component.<type>.method(), or make the call from inside a
coroutine, it is all handled correctly. Asynchrony only affects speed, not how you
write code. Most scripts should just use the synchronous version; it is only worth
switching to the asynchronous one when you have a computation-heavy background script that
you don't want to slow the server down. The APU versions come with a built-in graphics card
(saving a GPU slot); the asynchronous APU is especially good value, because graphics card
calls (gpu.set and friends) are direct and therefore run right on the compute thread with
no round trip.
The entire VM state is stored in the save file and fully restored when the save is loaded:
variables, the call stack, generators, coroutines, and the component proxies and wrapped
Values you obtained from components — all of it is frozen, and after loading a save it
continues in place from the moment you saved, rather than starting over.
This brings one thing to keep in mind when writing scripts: do not assume your script runs from the beginning. If you want some initialization to happen exactly once, on the "genuine first startup", you have to determine that yourself (for example by leaving a marker file on disk), because loading a save will not re-run it — it picks up from where it was suspended. Conversely, this also means you don't have to worry about losing progress when you quit the game: a long task that was half-finished will run to completion after the save is loaded.
Time is "frozen with the save" too, and no timer counts the time you were offline:
-
time.sleep/time.ticks_ms/time.timerun off the server tick clock (computer.uptime), which does not advance while saved, while the chunk is unloaded, or while the server is down; -
jasyncio.sleepand the coroutine pool's timers are accounted for in "milliseconds remaining" and only decrement while the VM is actually being driven.
So if time.sleep(5) is half done when you save and quit, and you come back an hour later, it
will finish the remaining part — it will neither wake up immediately nor wait any extra.
Do not try to use sleep to measure real-world time.
A script that shows a clock on screen and quits when you press Q. It brings together primary components, unpacking multiple return values, a signal loop, and yielding:
gpu = component.gpu
w, h = gpu.getResolution()
gpu.setForeground(0x00FF00)
running = True
while running:
# Draw the current uptime
text = "uptime: {:.1f}s (press Q to quit)".format(computer.uptime())
gpu.set(1, 1, text + " " * (w - len(text)))
# Wait for a signal, at most 1 second; we yield meanwhile, so ops accumulate
sig = computer.pullSignal(1)
if sig is not None and sig[0] == "key_down":
_, _, char, code, *_ = sig # trailing *_ : the signal may carry a player name
if char == ord("q") or char == ord("Q"):
running = False
gpu.set(1, 2, "bye")