Author Topic: Services mechanism  (Read 2550 times)

Offline chaos

  • BFF
  • ***
  • Posts: 290
  • Job, school, social life, sleep. Pick 2.5.
    • View Profile
    • Lost Souls
Services mechanism
« on: December 16, 2008, 01:37:24 AM »
Below is, I hope, all of the code implementing my 'services' mechanism, which is a method of using sefuns to accelerate access to well-known objects like system daemons and specific functions within them.  Its primary motivation is an examination of what happens when you do something like "/daemon/example"->foo(), which is fairly horrific (at least in LPmud and LDmud), with string reallocations before it even looks up /daemon/example and other such nonsense.  It also involves referring to those objects and functions by macros rather than strings, which I consider a benefit, both in visual aesthetics and insofar as typos become compile-time syntax errors instead of runtime errors.

This code is written for LDMud 3.2.15, and using it on another platform will require porting.

Current speed testing results on my system are:

Calling an undefined function in an object via string-LHS call_other() vs. calling the same undefined function in the same object via services-system-lookup LHS (example: "/daemon/weapons"->foo() vs. Daemon_Weapons->foo()): latter is 15% faster

Calling a fairly simple, defined function in an object via string-LHS call_other() vs. calling the same function in the same object via services-system-lookup LHS (example: "/daemon/weapons"->is_weapon_type(0) vs. Daemon_Weapons->is_weapon_type(0)): latter is 12% faster

Calling a fairly simple, defined function in an object via string-LHS call_other() vs. calling the same function in the same object via services system lookup direct to function (example: "/daemon/weapons"->is_weapon_type(0) vs. Is_Weapon_Type(0)): latter is 33% faster

Mostly, this is a way of helping a mudlib that heavily uses daemons (which generally becomes more prevalent as a lib design becomes more advanced) be less penalized in core system speed because of the execution path jumping between objects.  It derives this benefit by virtue of the sefun mechanism being considerably accelerated compared to call_other() to string LHSes.  If your driver does not have sefun acceleration like LDMud's or has better-optimized call_other()s, you may not see the same speed benefits.

The way the services system operates is by automatically generating header files like /lib/services/services_affiliations.h (for an object /daemon/affiliations) that contain macros for referring to the object and any functions it publishes.  The macros resolve to sefun calls producing the desired results.  (A master services file, /lib/services.h, containing macros for all the services the system tracks, is also published; this is better used for debugging than for production code, since it tends to become very long, to the point of seriously affecting object compile time.)  /lib/services/services_affiliations.h, on my system, looks like:

Code: [Select]
#ifndef _Services_Included
#ifndef _Services_Affiliations_Included
#define _Services_Affiliations_Included

// This file is maintained automatically by /daemon/services.c and should
// not be manually edited.  Changes made to this file will be lost.
//
// Further, do not *ever* use the service number identifiers in this file
// directly, or in any way rely on them to stay the same.  They *will* change.
// Use the macros.

#define Affiliation(a)                                          funcall(service(879), a)
#define Affiliation_Closure                                     service(879)
#define Affiliation_Maneuver_Access(a, b)                       funcall(service(880), a, b)
#define Affiliation_Maneuver_Access_Closure                     service(880)
#define Daemon_Affiliations                                     service(1)
#define Is_Affiliation(a)                                       funcall(service(881), a)
#define Is_Affiliation_Closure                                  service(881)

#endif
#endif

To start in on the actual code of the services system, first I'll include my /sys/global.h file, since it's referred to in files to follow and its macros are used extensively.  This file is configured to be universally included.

/sys/global.h:
Code: [Select]
#ifndef __GLOBAL_H__
#define __GLOBAL_H__

// Object namespace macros
#define Daemon(x)               ("/daemon/" + x)
#define Master_Object           __MASTER_OBJECT__

// Boolean and trinary values
#define True                    1
#define False                   0
#define Null                    (-1)

// Debugging macros
#define Debug(x)                (this_interactive() && this_interactive()->display(Daemon("debug")->dump_value(x)))
#define Debug_To(x, y)          (objectp(x) || find_object("/usr/" + (x)) ? (objectp(x) ? x : find_object("/usr/" + (x)))->display(Daemon("debug")->dump_value(y), 2) : 0)
#define Debug_For(x, y)         ((objectp(x) || find_object("/usr/" + (x))) && this_interactive() == (objectp(x) ? x : find_object("/usr/" + (x))) ? (objectp(x) ? x : find_object("/usr/" + (x)))->display(Daemon("debug")->dump_value(y), 2) : 0)
#define Debug_String(x)         Daemon("debug")->dump_value(x, 0, True)
#define Debug_Log(x)            log_file("lpmud.log", Debug_String(x))

// MudOS-alike macros
#define array                   *
#define arrayp                  pointerp
#define error                   raise_error
#define eval_cost               get_eval_cost
#define keys                    m_indices
#define map_delete              m_delete
#define new(x)                  clone_object(x)
#define values                  m_values

// Perl-alike pseudo control structures
#define unless(x)               if(!(x))
#define until(x)                while(!(x))

// pseudotype support
#define internal                private static
#define descriptor              mixed
#define struct                  mixed
#define structp                 pointerp
#define word_status(x)          ((x) ? "Yes" : "No")

// compat miscellany
#ifdef __LDMUD__
#define filter_array            filter
#define copy_mapping            copy
#define filter_mapping          filter
#define file_name               object_name
#define map_array               map
#else // __LDMUD__
#define abs(x)                  ((x) < 0 ? -(x) : (x))
#define set_environment         efun308
#define send_udp                send_imp
#define m_allocate              allocate_mapping
#define object_name             file_name
#endif // __LDMUD__

#endif // __GLOBAL_H__

/lib/service_conf.h, a configuration file for the services system.  It's assumed that this will be placed in some directory on your general include path.  Importantly, the Services macro defines the directories that should be scanned and services files produced for the objects in them, or individual files to be treated similarly.
Code: [Select]
#ifndef _Service_Conf_Included
#define _Service_Conf_Included

#define Services_File                 "/lib/services.h"
#define Services_Split_Dir            "/lib/services/"
#define Services                      ({\
    "/daemon",\
    "/def/descriptor",\
    __MASTER_OBJECT__ + ".c",\
})

#endif

/obj/master/sefun/services.c, the sefun module (inherited by /obj/master/simul_efun) that implements the support functions for the system.  Note: the start_simul_efun() function defined here needs to be called at the simul_efun object's create time.  /sys/lpctypes.h is, in my case, the lpctypes.h provided with LDMud.
Code: [Select]
// Services support module
//
// Factored from simul_efun.c by Chaos, Thu May 26 19:12:52 EDT 2005

#include "/sys/global.h"
#include "/sys/lpctypes.h"

string closure_name(closure func);
string printable(mixed val);

static mixed array services = ({});

void synchronize_services() {
    int size = Daemon("services")->query_services_count();
    if(sizeof(services) < size)
        services += allocate(size - sizeof(services));
}

status register_service() {
    object obj = previous_object();
    int identifier = Daemon("services")->query_service_identifier(obj);
    if(identifier == Null) {
        Daemon("services")->scan_services_locations();
        identifier = Daemon("services")->query_service_identifier(obj);
        if(identifier == Null)
            return False;
    }
    if(services[identifier])
        if(services[identifier] == obj)
            return True;
        else
            error("Duplicate service registration for " + printable(obj) + " vs. " + printable(services[identifier]));
    services[identifier] = obj;
    Daemon("services")->scan_service(obj);
    mixed array svc = obj->query_service_functions();
    if(svc) {
        string name;
        int id;
        closure func;
        foreach(mixed info : svc) {
            switch(typeof(info)) {
            case T_CLOSURE :
                name = closure_name(info);
                func = info;
                break;
            case T_POINTER :
                name = info[0];
                func = info[1];
                break;
            default        :
                error("Bad service function");
            }
            id = Daemon("services")->query_service_identifier(obj, name);
            if(id == Null)
                error("Could not find service identifier for function " + name + "() in " + printable(obj));
            services[id] = func;
        }
    }
    return True;
}

private mixed find_service(int identifier) {
    mixed location = Daemon("services")->query_service_location(identifier);
    if(!location)
        error("Service requested with unknown identifier " + identifier);
    string name = 0;
    sscanf(location, "%s::%s", location, name);
    object obj = load_object(location);
    Daemon("services")->scan_service(obj);
    if(name) {
        foreach(mixed func : obj->query_service_functions()) {
            switch(typeof(func)) {
            case T_CLOSURE :
                if(closure_name(func) == name)
                    return services[identifier] = func;
                break;
            case T_POINTER :
                if(func[0] == name)
                    return services[identifier] = func[1];
                break;
            default        :
                error("Bad service function");
            }
        }
        error("Cannot find function " + name + "() in service functions");
    } else {
        return services[identifier] = obj;
    }
}

mixed service(int identifier) {
    return services[identifier] || find_service(identifier);
}

mixed service_known(int identifier) {
    return services[identifier];
}

static void start_simul_efun() {
    if(find_object(Daemon("services")))
        synchronize_services();
}

/daemon/services.c, the main workhorse of the system.  Notes:
  • Daemon("reload_storage") is my mechanism for objects to temporarily store their state information in an external repository so it can be picked up by a later instance of themselves -- i.e. my way of making it so that daemons can be reloaded without losing their information.  You can just remove the lines referencing it without consequence.
  • The lines referencing Daemon("shutdown") are more important.  Daemon("shutdown") is, naturally, my handler for MUD shutdowns, and it supports being handed functions to call at shutdown time, as this daemon is doing.  Somehow or another, you need to do something equivalent and make sure that clear_services_files() is called right before the MUD shuts down, or bad things will happen.  (Mainly, you will probably get some objects compiled using services files from the previous runcycle, which is very likely to mean that the macros they were compiled with are completely wrong, which will cause all kinds of trouble.)
Code: [Select]
// Core system services access daemon
//
// by Chaos, Fri Mar 19 09:30:15 EST 2004

// This file does not and *must* not inherit /std/daemon or anything else,
// because if it does, its entire inheritance tree will be loaded before
// this file, which is bad, because to any extent that they use the services
// mechanism, they will either break or be loaded with slower code than
// would be the case if the services system were available.
//
// This file must not, of course, use services.h or any services_<name>.h
// file itself at all.

#include <files.h>
#include <functionlist.h>
#include <service_conf.h>

internal mapping files;
internal int count;

void verify_services_files();

void set_services_locations(mapping val) {
    files = val;
    count = 0;
    synchronize_services();
}

mapping query_services_locations() {
    return files;
}

void excise_service(string what) {
    map_delete(files, what);
}

string array clean_services() {
    string array out = ({});
    foreach(string what : files) {
        string file;
        string func;
        if(sscanf(what, "%s::%s", file, func) == 2) {
            closure array funcs = file->query_service_functions();
            if(!funcs || member(map(funcs, (: closurep($1) ? closure_name($1) : $1[0] :)), func) == Null)
                out += ({ what });
        } else {
            object obj = find_object(what);
            if(obj)
                continue;
            if(file_size(what) != FSIZE_NOFILE)
                continue;
            out += ({ what });
        }
    }
    if(sizeof(out)) {
        foreach(string what : out)
            map_delete(files, what);
        verify_services_files();
    }
    return out;
}

int query_services_count() {
    return count ||= sizeof(files) ? max(values(files)) + 1 : 0;
}

varargs int query_service_identifier(mixed target, string func) {
    if(objectp(target))
        target = object_name(target) + ".c";
    else if(!ends_with(target, ".c"))
        target += ".c";
    if(func)
        target += "::" + func;
    return query_services_locations()[target] || Null;
}

string query_service_location(int target) {
    foreach(string file, int identifier : query_services_locations())
        if(identifier == target)
            return file;
    return 0;
}

private string service_split_filename(string service) {
    if(ends_with(service, ".c"))
        service = service[..<3];
    return Services_Split_Dir + "services_" + explode(service, "/")[<1] + ".h";
}

private string service_location_split_filename(string location) {
    sscanf(location, "%s::%!s", location);
    return service_split_filename(location);
}

private string array service_functions(object obj) {
    string array out = ({});
    mixed array functions = obj->query_service_functions();
    if(functions) {
        foreach(mixed func : functions) {
            switch(typeof(func)) {
            case T_CLOSURE :
                out += ({ object_name(obj) + ".c::" + closure_name(func) });
                break;
            case T_POINTER :
                if(sizeof(func) == 2 && stringp(func[0]) && closurep(func[1])) {
                    out += ({ object_name(obj) + ".c::" + func[0] });
                    break;
                }
                // fallthrough
            default        :
                error("Bad service function from " + printable(obj) + ", " + printable(func));
            }
        }
    }
    return out;
}

private string array service_location_locate(string where) {
    string array out = ({});
    switch(file_size(where)) {
    case FSIZE_DIR    :
        if(member(where, '.') == -1) {
            if(where[<1] != '/')
                where += "/";
            foreach(string file : get_dir(where, GETDIR_PATH))
                out += service_location_locate(file);
        }
        break;
    case FSIZE_NOFILE :
        warn("tried to find service files in nonexistent target " + where);
        break;
    default           :
        if(!ends_with(where, ".c"))
            break;
        out += ({ where });
        object obj = find_object(where);
        if(obj)
            out += service_functions(obj);
        break;
    }
    return out;
}

private string array find_services_locations() {
    string array out = ({});
    foreach(string item : Services)
        out += service_location_locate(item);
    return out;
}

private void add_locations(string array add, mapping current) {
    int index = sizeof(current) ? max(values(current)) + 1 : 0;
    add = sort_array(add, #'>);
    foreach(string file : add)
        current[file] = index++;
    set_services_locations(current);
    verify_services_files();
}

void scan_services_locations() {
    string array add = ({});
    mapping current = query_services_locations();
    foreach(string location : find_services_locations())
        if(!member(current, location))
            add += ({ location });
    if(sizeof(add))
        add_locations(add, current);
}

void scan_service(object obj) {
    string array add = ({});
    mapping current = query_services_locations();
    foreach(string location : service_functions(obj))
        if(!member(current, location))
            add += ({ location });
    if(sizeof(add))
        add_locations(add, current);
}

private mapping retrieve_function_table(string file) {
    object obj;
    catch(obj = load_object(file));
    unless(obj)
        return ([]);
    int flags = TYPE_MOD_STATIC | TYPE_MOD_PRIVATE | TYPE_MOD_PROTECTED | NAME_HIDDEN;
    return mkmapping(functionlist(obj, flags | RETURN_FUNCTION_NAME), functionlist(obj, flags | RETURN_FUNCTION_NUMARG));
}

private varargs string service_file_header(string file, status interstitial) {
    if(ends_with(file, ".h"))
        file = file[..<3];
    if(member(file, '/') != Null)
        file = explode(file, "/")[<1];
    string macro = capitalize_words(regreplace(file, "[^A-Za-z0-9_]", "_", 1));
    string out = "";
    out += "#ifndef _Services_Included\n";
    if(file != "services")
        out += "#ifndef _" + macro + "_Included\n";
    out += "#define _" + macro + "_Included\n";
    out += "\n";
    out += "// This file is maintained automatically by /daemon/services.c and should\n";
    out += "// not be manually edited.  Changes made to this file will be lost.\n";
    out += "//\n";
    if(interstitial) {
        out += "// This is the interstitial version of the file, meant to be put in place\n";
        out += "// immediately before shutdown so that code compiled early in the boot\n";
        out += "// process is not hooked into the services mechanism using outdated service\n";
        out += "// ID numbers.\n";
    } else {
        out += "// Further, do not *ever* use the service number identifiers in this file\n";
        out += "// directly, or in any way rely on them to stay the same.  They *will* change.\n";
        out += "// Use the macros.\n";
    }
    out += "\n";
    return out;
}

private string service_file_footer(string file) {
    if(ends_with(file, ".h"))
        file = file[..<3];
    if(member(file, '/') != Null)
        file = explode(file, "/")[<1];
    string out = "";
    out += "\n";
    out += "#endif\n";
    if(file != "services")
        out += "#endif\n";
    return out;
}

mapping generate_services_content() {
    mapping files = ([]);
    string main = service_file_header(Services_File);
    mapping macro_action = ([]);
    mapping macro_split_filename = ([]);
    mapping macro_base = ([]);
    string action;
    string func;
    mapping tables = ([]);
    mapping table;
    mapping functions = ([]);
    status attach;
    if(file_size(Services_Split_Dir) != FSIZE_DIR)
        if(!mkdir(Services_Split_Dir))
            warn("cannot create services split directory " + printable(Services_Split_Dir));
    foreach(mixed location : query_services_locations())
        if(sscanf(location, "%s::%s", location, func))
            functions[func]++;
    int args;
    foreach(string location, int ident : query_services_locations()) {
        while(location[0] == '/')
            location = location[1..];
        func = 0;
        sscanf(location, "%s::%s", location, func);
        if(ends_with(location, ".c"))
            location = location[..<3];
        string split_filename = service_split_filename(location);
        if(func) {
            table = tables[location] ||= retrieve_function_table(location);
            args = table[func];
            if(functions[func] > 1) {
                location += "_" + func;
                attach = True;
            } else {
                location = func;
                attach = False;
            }
        }
        string base = capitalize_words(regreplace(location, "[^A-Za-z0-9_]", "_", 1));
        string macro = base;
        if(func) {
            unless(attach) {
                string macro_mod = macro + "_Closure";
                macro_action[macro_mod] = "service(" + ident + ")";
                macro_split_filename[macro_mod] = split_filename;
                macro_base[macro_mod] = macro_mod;
            }
            string array labels = ({});
            for(int index = 0; index < args; index++)
                labels += ({ sprintf("%c", 'a' + index) });
            if(args)
                macro += "(" + implode(labels, ", ") + ")";
            action = "funcall(service(" + ident + ")";
            if(args)
                action += ", " + implode(labels, ", ");
            action += ")";
            macro_action[macro] = action;
        } else {
            macro_action[macro] = "service(" + ident + ")";
        }
        macro_split_filename[macro] = split_filename;
        macro_base[macro] = base;
    }
    string array macros = sort_array(keys(macro_action), #'>);
    int width = sizeof(macros) ? max(map(macros, #'strlen)) : 0;
    foreach(string macro : macros) {
        action = macro_action[macro];
        string line;
        if(width + strlen(action) + 9 >= 132)
            line = "#define " + left_justify(macro, width) + " \\\n    " + action + "\n";
        else
            line = "#define " + left_justify(macro, width) + " " + action + "\n";
        string split_filename = macro_split_filename[macro];
        files[split_filename] ||= service_file_header(split_filename);
        files[split_filename] += line;
        main += "#ifndef " + macro_base[macro] + "\n";
        main += line;
        main += "#endif\n";
    }
    files[Services_File] = main;
    foreach(string file : files)
        files[file] += service_file_footer(file);
    return files;
}

mapping generate_interstitial_content() {
    mapping files = ([]);
    string main = service_file_header(Services_File, True);
    mapping macro_action = ([]);
    mapping macro_split_filename = ([]);
    mapping macro_base = ([]);
    string action;
    string func;
    mapping tables = ([]);
    mapping table;
    mapping functions = ([]);
    status attach;
    foreach(mixed location : query_services_locations())
        if(sscanf(location, "%s::%s", location, func))
            functions[func]++;
    int args;
    foreach(string location : query_services_locations()) {
        func = 0;
        sscanf(location, "%s::%s", location, func);
        if(ends_with(location, ".c"))
            location = location[..<3];
        string filename = location;
        if(filename[0] != '/')
            filename = "/" + filename;
        while(location[0] == '/')
            location = location[1..];
        string split_filename = service_split_filename(location);
        if(func) {
            table = tables[location] ||= retrieve_function_table(location);
            args = table[func];
            if(functions[func] > 1) {
                location += "_" + func;
                attach = True;
            } else {
                location = func;
                attach = False;
            }
        }
        string base = capitalize_words(regreplace(location, "[^A-Za-z0-9_]", "_", 1));
        string macro = base;
        if(func) {
            unless(attach) {
                string macro_mod = macro + "_Closure";
                macro_action[macro_mod] = "func(\"" + filename + "\", \"" + func + "\")";
                macro_split_filename[macro_mod] = split_filename;
                macro_base[macro_mod] = base;
            }
            string array labels = ({});
            for(int index = 0; index < args; index++)
                labels += ({ sprintf("%c", 'a' + index) });
            if(args)
                macro += "(" + implode(labels, ", ") + ")";
            action = "\"" + filename + "\"->" + func + "(";
            if(args)
                action += implode(labels, ", ");
            action += ")";
            macro_action[macro] = action;
        } else {
            macro_action[macro] = "load_object(\"" + filename + "\")";
        }
        macro_split_filename[macro] = split_filename;
        macro_base[macro] = base;
    }
    string array macros = sort_array(keys(macro_action), #'>);
    int width = sizeof(macros) ? max(map(macros, #'strlen)) : 0;
    foreach(string macro : macros) {
        action = macro_action[macro];
        string line;
        if(width + strlen(action) + 9 >= 132)
            line = "#define " + left_justify(macro, width) + " \\\n    " + action + "\n";
        else
            line = "#define " + left_justify(macro, width) + " " + action + "\n";
        string split_filename = macro_split_filename[macro];
        files[split_filename] ||= service_file_header(split_filename);
        files[split_filename] += line;
        main += "#ifndef " + macro_base[macro] + "\n";
        main += line;
        main += "#endif\n";
    }
    files[Services_File] = main;
    foreach(string file : files)
        files[file] += service_file_footer(file);
    return files;
}

void verify_services_files() {
    mapping files = generate_services_content();
    foreach(string file, string content : files) {
        if(content != read_file(file)) {
            rm(file);
            write_file(file, content);
        }
    }
}

private void clear_services_files() {
    mapping files = generate_interstitial_content();
    foreach(string file, string content : files) {
        if(content != read_file(file)) {
            rm(file);
            write_file(file, content);
        }
    }
}

void daemon_maintain_reset() {
    // This function is an asinine hack to forcibly suppress the even more asinine driver behavior that thinks objects don't need
    // reset() called if nothing has done a call_other() to them in some period of time.  If your driver doesn't do stupid things
    // like that, you don't need this.
    this_object()->load();
}

void preinit() {
    seteuid(getuid());
    object dmn = find_object(Daemon("reload_storage"));
    files = (dmn && dmn->retrieve_information(this_object())) || ([]);
    count = 0;
}

void create() {
    unless(sizeof(files))
        clear_services_files();
    scan_services_locations();
    synchronize_services();
    Daemon("shutdown")->add_shutdown_notify(#'clear_services_files);
}

void reset() {
    scan_services_locations();
    call_out("daemon_maintain_reset", 30);
}

void remove() {
    Daemon("reload_storage")->deposit_information(this_object(), files);
    Daemon("shutdown")->remove_shutdown_notify(#'clear_services_files);
    destruct(this_object());
}

object load() {
    return this_object();
}

That's just about it, then.  A couple last things:
  • Whatever objects are in your Services directories need to call the sefun register_service() from their create() in order to sync them up properly with the system.  I mostly accomplish this by having a /std/daemon that they inherit which handles the issue for them.
  • In order for your service objects to provide direct access to their functions, they need to define a function closure array query_service_functions() that returns an array of closures of the functions they want to publish.  For instance, this function as defined by my /daemon/affiliations, used as an example above, looks like:

Code: [Select]
closure array query_service_functions() {
    return ({
        #'is_affiliation,
        #'affiliation,
        #'affiliation_maneuver_access,
    });
}

Then, once the services daemon is loaded and generating its files, add /lib/services (or wherever you told it to put them) to your include path, #include <services_whatever.h>, and use the macros from it.

Offline chaos

  • BFF
  • ***
  • Posts: 290
  • Job, school, social life, sleep. Pick 2.5.
    • View Profile
    • Lost Souls
Re: Services mechanism
« Reply #1 on: December 16, 2008, 01:47:20 AM »
A further note on using direct function access.  While the macros for accessing the objects involved can be, and are, generated before the objects are loaded, the published functions cannot be, since the object is queried for them.  That means that the macros for function access will only exist once the object is loaded.  That, in turn, means that you will generally need 'failover' versions of the macros in static header files to take care of any code that loads before the objects in question.  For example, I have a file /lib/weapons.h that includes (among other things) this:
Code: [Select]
#ifndef _Weapons_Included
#define _Weapons_Included

#include <services_weapons.h>

#ifndef Is_Weapon_Type
#define Is_Weapon_Type(x)                               Daemon_Weapons->is_weapon_type(x)
#endif
#ifndef Weapon_Type
#define Weapon_Type(x)                                  Daemon_Weapons->weapon_type(x)
#endif

#endif

That way, code that compiles before the services versions of Is_Weapon_Type() and Weapon_Type() are available uses slower, but still working, methods of calling the functions, and as soon as the services macros become available, objects compiled after that point use the fully accelerated macros.

Offline chaos

  • BFF
  • ***
  • Posts: 290
  • Job, school, social life, sleep. Pick 2.5.
    • View Profile
    • Lost Souls
Re: Services mechanism
« Reply #2 on: February 02, 2009, 05:00:02 PM »
Also, everything above is released into the public domain.  I keep forgetting to do that...