2017-04-09
Exploring 3-Move - A LambdaMOO inspired environment
I was a fan of MUDs from my earliest introduction to computers. I remember writing to Richard Bartle when I was young asking about the possiblity of accessing MUD1 from New Zealand after having read about it in a magazine. The reply was very positive but unfortunately the cost of 300 baud modem access at international phone rates was prohibitive. It was later in life that my first use of the internet and a shell account on my ISP was to compile and run a MUD client.
The MOO variants of MUDs are particularly interesting as they are multi user, programmable, interactive systems. They're like IRC where users can create objects, rooms and worlds by writing programs within the system. This resulted in systems with interesting programming systems with permission models for different levels of users. Content, including code, was stored in a persistent object database. LambdaMOO was a very popular instance of a MOO.
A while back I stumbled across 3-Move, a multi user networked online text-based programmable environment, by Tony Garnock-Jones. It's a neat system that includes:
- Persistent object-oriented database
- A MOO inspired security model
- Prototype-based object-oriented language
- First-class functions, continuations and preemptive green threads.
There's not much written in the way of documentation on getting it running so this post documents how I got it working. It appears to not be actively developed anymore but it's a nice small system to learn from.
Building
Building 3-move requires cloning the source and running make
:
$ git clone https://github.com/tonyg/3-move
$ make
$ ./move/move
move [-t] <dbfilename> [<move-source-code-file> ...]
This produces the move
executable which is the virtual machine and a checkpoint-cleanup
which is a helper program to clean up database checkpoint files.
The move
executable requires a database as an argument. This database stores the state of the persistent world. It's loaded in memory when move
is run and can be saved by occasionally checkpointing the system. Optional arguments are move
source code files that are compiled and executed.
In the db directory are a number of move
source files that contain the code for a multi user virtual environment. The command parser, socket usage, line editor, etc are all written in these move
files.
Database
To create an initial database there is a build
script that creates a database with the content of the move
file. It can be run with:
$ cd db
$ ./build
This creates the database in a file, db
, and a symbolic link to the move
executable in the current directory for easy execution. All the build
script does is run move
on files in the right order to build the database. It's equivalent to:
$ ./move db root.move && mv move.checkpoint.000 db
$ ./move db system.move && mv move.checkpoint.000 db
$ ./move db thing.move && mv move.checkpoint.000 db
$ ./move db login.move && mv move.checkpoint.000 db
$ ./move db player.move && mv move.checkpoint.000 db
$ ./move db room.move && mv move.checkpoint.000 db
$ ./move db exit.move && mv move.checkpoint.000 db
$ ./move db note.move && mv move.checkpoint.000 db
$ ./move db program.move && mv move.checkpoint.000 db
$ ./move db registry.move && mv move.checkpoint.000 db
Each run of move
creates a new database called move.checkout.000
, containing the state of the old database plus any changes made by the move file
. This is then renamed back to db
and run again on the next file. The end result is a complete database with a default virtual environment.
Running
With the database built the system can be run with:
$ ./move db restart.move
restart.move calls start-listening()
to start the socket server accepting connections:
set-realuid(Wizard);
set-effuid(Wizard);
start-listening();
It calls the set-realuid
and set-effuid
functions to Wizard
before calling to ensure that the system can access the default "Wizard" user which has full permissions to call the socket related functions.
start-listening
is implemented in login.move. It creates a server socket that accepts connections on port 7777
. It can be connected to via telnet
, netcat
, or similar program:
$ nc 127.0.0.1 7777
_/ _/ _/_/_/ _/ _/ _/_/_/_/_/
_/_/ _/_/ _/ _/ _/ _/ _/
_/ _/ _/ _/ _/ _/ _/ _/_/_/
_/ _/ _/ _/ _/ _/
_/ _/ _/_/_/ _/ _/_/_/_/_/
3-MOVE Copyright (C) 1997--2009 Tony Garnock-Jones.
This program comes with ABSOLUTELY NO WARRANTY; for details see
https://leastfixedpoint.com/tonyg/kcbbs/projects/3-move.html.
This is free software, and you are welcome to redistribute it
under certain conditions; see http://github.com/tonyg/3-move for
details.
login:
The database only contains one user, Wizard
, to begin with. It has no password:
login: Wizard
Password:
Logging in as player Wizard...
Welcome to MOVE.
Generic Room
~~~~~~~~~~~~
This is a nondescript room.
Wizard is here.
The @verbs
command can be used to find out what commands can be sent to objects:
@verbs me
Verbs defined on Wizard (#7) and it's parents:
(Wizard (#7))
@shutdown
@checkpoint
(Generic Player (#2))
look
@setpass <pass>
...
@verbs here
Verbs defined on Generic Room (#3) and it's parents:
(Generic Room (#3))
say <sent>
emote<sent>
@@shout <sent>
...
@examine
is another useful verb for finding out internal details of an object:
@examine me
Wizard (#7) (owned by Wizard (#7))
Location: #<object Generic Room (#3)>
Contents: [Registry (#0), Generic Program (#6), Generic Note (#5), Generic Exit (#4),
Generic Thing (#1)]
Parent(s): Generic Player (#2)
Methods: [@checkpoint-verb, @shutdown-verb]
Slots: [verbs, connection, is-programmer, registry-number, name, awake]
Verbs: [@shutdown-verb, @checkpoint-verb]
It's important to set a password when first logging in:
@setpass ********
Password changed.
Users
A multi user environment without other users isn't much fun. Guest users can be added with:
@build Guest as guest1
You created an object.
You named it "guest1".
It was registered as guest1 (#9).
These are special users in that any login name of guest
will pick from the current guest users that are not logged in. This allows people to explore the system without creating a user. Specific users can also be created:
@build Player as chris
You created an object.
You named it "chris".
It was registered as chris (#10).
Here's an example interaction of the chris
user logging in:
@setpass foo
Password changed.
@describe chris
Editing description of #<object chris (#10)>.
Type .s to save, or .q to lose changes. .? is for help.
2> .l
--- Current text:
1> You see a player who needs to @describe %r.
2> .d 1
1> An amorphous blob shimmers in the light.
2> .s
Setting description...
Description set.
look at me
chris
~~~~~
An amorphous blob shimmers in the light.
(chris is awake.)
Creating Rooms
Wizards can create rooms and routes to them with @dig
:
@dig here to Large Room as north
You dig the backlink exit, named "out", from "Large Room" to "Generic Room (#3)".
You dig the outward exit, named "north", from "Generic Room (#3)" to "Large Room".
look
Generic Room
~~~~~~~~~~~~
This is a nondescript room.
chris, guest1 and Wizard are here.
Obvious exits: north to Large Room
north
Large Room
~~~~~~~~~~
This is a nondescript room.
Wizard is here.
Obvious exits: out to Generic Room
Normal users can create rooms but can't dig paths to the new room inside an existing room they didn't create themselves. They can use go to
to go to the room created and build up rooms from there. A friendly Wizard can link the rooms later if desired. The room logic is in room.move.
Programs
Programs can be written and executed within the environment. This is done by creating a Program
object, editing it and compiling it:
@build Program as hello
You created an object.
You named it "hello".
It was registered as hello (#11).
edit hello
Type .s to save, or .q to lose changes. .? is for help.
1> realuid():tell("Hello World\n");
2> .s
Edit successful.
@compile hello
Hello World
Result: undefined
This "hello world" example gets the current user with realuid
and calls the tell
method which sends output to that users connection.
The code for the Program
object is in program.move. Note that the @compile
verb wraps the code from the program inside a "function (TARGET) { ...code here... }
". TARGET
can be set using the @target
verb on a program. This enables writing programs that can add verbs to objects. The tricks subdirectory has some example of this, for example ps.verbs.move that adds the @ps
verb to the target:
define method (TARGET) @ps-verb(b) {
define player = realuid();
if (player != TARGET) {
player:tell("You don't have permission to @ps, I'm sorry.\n");
} else {
define tab = get-thread-table();
player:mtell(["Process table:\n"] + map(function(p) {
" #" + get-print-string(p[0]) + "\t" +
p[1].name + "\t\t" +
get-print-string(p[2]) + "\t" +
get-print-string(p[3]) + "\n";
}, tab));
}
}
TARGET:add-verb(#this, #@ps-verb, ["@ps"]);
If that text is copied and pasted into a program, then @ps
can be added to an object with:
@build Program as psprog
You created an object.
You named it "psprog".
It was registered as psprog (#13).
edit psprog
Type .s to save, or .q to lose changes. .? is for help.
1> ...paste program code above...
2> .s
@target psprog at me
You target psprog (#13) at Wizard (#7).
@compile psprog
Result: true
@verbs me
Verbs defined on Wizard (#7) and it's parents:
(Wizard (#7))
@shutdown
@checkpoint
@ps
...
@ps
Process table:
#1 Wizard false 2
#2 Wizard false 0
#3 chris false 1
Checkpointing
All changes to the system are done in memory. A checkpoint
method should be called occasionally to save the current state of the database. An example of how to do this is in checkpoint.move but it can also be done by any Wizard calling @checkpoint
.
Checkpoints don't overwrite the existing database - they save to a new file of the form move.checkpoint.000
, where 000
is an incrementing number. When restarting a system it's important to use the last checkpoint to start from.
Programming Language
The programming language used by 3-Move is undocumented but it's pretty easy to follow from the examples. The primitives can be seen in the PRIM.*.c
files in the move directory. Functions are of the form:
define function this-is-a-function(arg1, argn) {
...
}
The system uses a prototype object system. Objects are created by calling clone
on an existing object:
// Create an object cloned from the Root object
define c = Root:clone();
Objects can have fields. These are defined as:
// Define a blah field of the new object and give it a value
define (c) blah = "foo";
// Access the blah field
c.blah;
Objects can have methods:
// Define a constructor for 'c' which gets called when cloned
define method (c) initialize() {
as(Root):initialize();
this.blah = "new blah";
}
Fields are accessed using the dot operator (.
) and methods with the colon operator (:
). There are separate namespace for fields and methods.
Objects and fields can have flags set to control permissions. An example from the source:
// Only the owner of an object can see the connection field
define (Player) connection = null;
set-slot-flags(Player, #connection, O_OWNER_MASK);
The flags are:
define O_OWNER_MASK = 0x00000F00;
define O_GROUP_MASK = 0x000000F0;
define O_WORLD_MASK = 0x0000000F;
define O_ALL_R = 0x00000111;
define O_ALL_W = 0x00000222;
define O_ALL_X = 0x00000444;
define O_OWNER_R = 0x00000100;
define O_OWNER_W = 0x00000200;
define O_OWNER_X = 0x00000400;
define O_GROUP_R = 0x00000010;
define O_GROUP_W = 0x00000020;
define O_GROUP_X = 0x00000040;
define O_WORLD_R = 0x00000001;
define O_WORLD_W = 0x00000002;
define O_WORLD_X = 0x00000004;
From within a method it's possible to query the user that called it and from there dynamically check permissions:
define method (Thing) add-alias(n) {
if (caller-effuid() != owner(this) && !privileged?(caller-effuid()))
return false;
this.aliases = this.aliases + [n];
return true;
}
This ensures that add-alias
can only be called on the Thing
object if the caller is the owner of the object and if they are a privileged user. Another example is:
define method (Thing) add-verb(selfvar, methname, pattern) {
define c = caller-effuid();
define fl = object-flags(this);
if ((fl & O_WORLD_W == O_WORLD_W) ||
((fl & O_GROUP_W == O_GROUP_W) && in-group-of(c, this)) ||
((fl & O_OWNER_W == O_OWNER_W) && c == owner(this)) ||
privileged?(c)) {
...
}
}
Here add-verb
can only be called if the object is world writeable, or group writeable and the caller is a member of the group, or owner writeable and the caller is the owner, or the caller is privileged.
Objects can also have flags set:
define method (Root) clone() {
define n = the-clone(this);
if (n) {
set-object-flags(n, O_NORMAL_FLAGS);
n:initialize();
}
n;
}
set-setuid(Root:clone, false);
Anonymous functions and higher order functions are available. Unfortunately there's no REPL but snippets can be tested in Program
objects when logged in, or added to a move
file and executed against the database. The result will be printed as part of the output:
define function reduce(f, st, vec) {
define i = 0;
while (i < length(vec)) {
st = f(st, vec[i]);
i = i + 1;
}
st;
}
reduce(function(acc, al) acc + al, 0, [1, 2, 3, 4, 5]);
$ ./move x reduce.move
importing test2.move
--> true
--> 15
-->! the compiler returned NULL.
Lightweight threads are spawned using fork
and fork/quota
. The first version, fork
takes a function to spawn in the background. It uses a default CPU quota of 15,000 cycles before it terminates:
fork(function () {
while (true) {
...do something...
// sleep for one second
sleep(1);
}
});
Threads are saved in the database when checkpointed and resumed when the database is started. fork/quota
allows setting a quota value other than the default of 15,000 cycles. It also allows three special values. A quota value of 0
means the thread should exit as soon as possible. -1
means the thread should run forever, with no quota, and can be checkpointed and resumed on restart like normal threads. A value of -2
means the thread runs forever but is not checkpointed and therefore not resumed at startup.
#define VM_STATE_DYING 0
#define VM_STATE_DAEMON -1
#define VM_STATE_NOQUOTA -2
fork/quota(function () {
while (true) {
...do something...
// sleep for one second
sleep(1);
}
}, VM_STATE_DAEMON);
The language has support for first class continuations via the call/cc
primitive. This works the same as the scheme call-with-current-continuation function. An example from the wikipedia page:
define function foo(ret) {
ret(2);
3;
}
foo(function (x) x); // Returns 3
call/cc(foo); // Returns 2
Tricks
There is a tricks directory that contains utility code and examples. This includes an http server written in move
, code for sending mail via smtp, and some bot examples.
Conclusion
The move language looks quite nice. I suspect it'd be useful for things other than virtual worlds - server side applications that can be extended with scripts safely are a common use case. I've put an example server on bluishcoder.co.nz
port 7777
to experiment with. There are a few guest accounts configured:
$ nc bluishcoder.co.nz 7777
There's a port for SSL connections on 7778 that can be connected via:
$ openssl s_client -connect bluishcoder.co.nz:7778
The SSL connection was set up on the server using socat to forward to the 7777 port:
$ openssl genrsa -out move.key 2048
$ openssl req -new -key move.key -x509 -days 3653 -out move.crt
$ socat openssl-listen:7778,reuseaddr,pf=ip4,fork,cert=./move.pem,verify=0 TCP:127.0.0.1:7777