From 6800761863dd45b6055768febc6ace6a20120dc7 Mon Sep 17 00:00:00 2001
From: Ben Longbons <b.r.longbons@gmail.com>
Date: Sun, 19 Oct 2014 22:22:08 -0700
Subject: New ast module for for npc parsing

Will eventually put most/all parsers there.
---
 src/ast/fwd.hpp               |  27 ++
 src/ast/npc.cpp               | 594 ++++++++++++++++++++++++++++++++++++++++++
 src/ast/npc.hpp               | 135 ++++++++++
 src/ast/npc_test.cpp          | 569 ++++++++++++++++++++++++++++++++++++++++
 src/ast/script.cpp            |  71 +++++
 src/ast/script.hpp            |  94 +++++++
 src/compat/result.hpp         |  78 ++++++
 src/compat/result_test.cpp    |  79 ++++++
 src/io/line.hpp               |  13 +
 src/io/line_test.cpp          |  16 +-
 src/io/read.cpp               |   2 +
 src/io/read_test.cpp          |   6 +
 src/io/write.cpp              |   2 +
 src/map/clif.cpp              |  34 +++
 src/map/clif.hpp              |   2 +
 src/mmo/extract.cpp           | 179 +++++++++++++
 src/mmo/extract.hpp           |  11 +-
 src/mmo/extract_test.cpp      |  88 +++++++
 src/sexpr/lexer_test.cpp      |  10 +-
 src/sexpr/parser_test.cpp     |   3 +
 src/strings/rstring.cpp       |  19 +-
 src/strings/strings2_test.cpp |   9 +
 22 files changed, 2025 insertions(+), 16 deletions(-)
 create mode 100644 src/ast/fwd.hpp
 create mode 100644 src/ast/npc.cpp
 create mode 100644 src/ast/npc.hpp
 create mode 100644 src/ast/npc_test.cpp
 create mode 100644 src/ast/script.cpp
 create mode 100644 src/ast/script.hpp
 create mode 100644 src/compat/result.hpp
 create mode 100644 src/compat/result_test.cpp

(limited to 'src')

diff --git a/src/ast/fwd.hpp b/src/ast/fwd.hpp
new file mode 100644
index 0000000..77328c9
--- /dev/null
+++ b/src/ast/fwd.hpp
@@ -0,0 +1,27 @@
+#pragma once
+//    ast/fwd.hpp - list of type names for new independent tmwa ast
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "../sanity.hpp"
+
+
+namespace tmwa
+{
+// meh, add more when I feel like it
+} // namespace tmwa
diff --git a/src/ast/npc.cpp b/src/ast/npc.cpp
new file mode 100644
index 0000000..362943c
--- /dev/null
+++ b/src/ast/npc.cpp
@@ -0,0 +1,594 @@
+#include "npc.hpp"
+//    ast/npc.cpp - Structure of non-player characters (including mobs).
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "../io/cxxstdio.hpp"
+
+#include "../mmo/extract.hpp"
+
+#include "../map/clif.hpp"
+
+#include "../poison.hpp"
+
+
+namespace tmwa
+{
+namespace npc
+{
+namespace parse
+{
+    // separate file because virtual
+    TopLevel::~TopLevel() {}
+
+#define TRY_EXTRACT(bit, var) ({ if (!extract(bit.data, &var.data)) return Err(bit.span.error_str("failed to extract"_s #var)); var.span = bit.span; })
+
+    static
+    Result<Warp> parse_warp(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits)
+    {
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 3)
+        {
+            return Err(bits[0].span.error_str("expect 3 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "warp"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits[3].data.size() != 5)
+        {
+            return Err(bits[3].span.error_str("expect 5 ,component,s"_s));
+        }
+
+        Warp warp;
+        warp.span = span;
+        TRY_EXTRACT(bits[0].data[0], warp.m);
+        TRY_EXTRACT(bits[0].data[1], warp.x);
+        TRY_EXTRACT(bits[0].data[2], warp.y);
+        warp.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], warp.name);
+        TRY_EXTRACT(bits[3].data[0], warp.xs);
+        TRY_EXTRACT(bits[3].data[1], warp.ys);
+        TRY_EXTRACT(bits[3].data[2], warp.to_m);
+        TRY_EXTRACT(bits[3].data[3], warp.to_x);
+        TRY_EXTRACT(bits[3].data[4], warp.to_y);
+        return Ok(std::move(warp));
+    }
+    static
+    Result<Shop> parse_shop(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits)
+    {
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 4)
+        {
+            return Err(bits[0].span.error_str("expect 4 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "shop"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits[3].data.size() < 2)
+        {
+            return Err(bits[3].span.error_str("expect at least 2 ,component,s"_s));
+        }
+
+        Shop shop;
+        shop.span = span;
+        TRY_EXTRACT(bits[0].data[0], shop.m);
+        TRY_EXTRACT(bits[0].data[1], shop.x);
+        TRY_EXTRACT(bits[0].data[2], shop.y);
+        TRY_EXTRACT(bits[0].data[3], shop.d);
+        shop.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], shop.name);
+        TRY_EXTRACT(bits[3].data[0], shop.npc_class);
+        shop.items.span = bits[3].span;
+        shop.items.span.begin = bits[3].data[1].span.begin;
+        for (size_t i = 1; i < bits[3].data.size(); ++i)
+        {
+            shop.items.data.emplace_back();
+            auto& item = shop.items.data.back();
+            auto& data = bits[3].data[i];
+            assert(data.span.begin.line == data.span.end.line);
+            item.span = data.span;
+
+            XString name;
+            if (!extract(data.data, record<':'>(&name, &item.data.value.data)))
+                return Err(data.span.error_str("Failed to split item:value"_s));
+            item.data.name.span = item.span;
+            item.data.name.span.end.column = item.data.name.span.begin.column + name.size() - 1;
+            item.data.value.span = item.span;
+            item.data.value.span.begin.column = item.data.name.span.begin.column + name.size() + 1;
+            if (name.endswith(' '))
+            {
+                item.data.name.span.warning("Shop item has useless space before the colon"_s);
+                name = name.rstrip();
+            }
+            if (name.is_digit10())
+            {
+                item.data.name.span.warning("Shop item is an id; should be a name"_s);
+            }
+            if (!extract(name, &item.data.name.data))
+                return Err("item name problem (too long?)"_s);
+        }
+        return Ok(std::move(shop));
+    }
+    static
+    Result<Monster> parse_monster(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits)
+    {
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 3 && bits[0].data.size() != 5)
+        {
+            return Err(bits[0].span.error_str("expect 3 or 5 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "monster"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits[3].data.size() != 2 && bits[3].data.size() != 4 && bits[3].data.size() != 5)
+        {
+            return Err(bits[3].span.error_str("expect 2, 4, or 5 ,component,s"_s));
+        }
+
+        Monster mob;
+        mob.span = span;
+        TRY_EXTRACT(bits[0].data[0], mob.m);
+        TRY_EXTRACT(bits[0].data[1], mob.x);
+        TRY_EXTRACT(bits[0].data[2], mob.y);
+        if (bits[0].data.size() >= 5)
+        {
+            TRY_EXTRACT(bits[0].data[3], mob.xs);
+            TRY_EXTRACT(bits[0].data[4], mob.ys);
+        }
+        else
+        {
+            mob.xs.data = 0;
+            mob.xs.span = bits[0].data[2].span;
+            mob.xs.span.end.column++;
+            mob.xs.span.begin.column = mob.xs.span.end.column;
+            mob.ys.data = 0;
+            mob.ys.span = mob.xs.span;
+        }
+        mob.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], mob.name);
+        TRY_EXTRACT(bits[3].data[0], mob.mob_class);
+        TRY_EXTRACT(bits[3].data[1], mob.num);
+        if (bits[3].data.size() >= 4)
+        {
+            static_assert(std::is_same<decltype(mob.delay1.data)::period, std::chrono::milliseconds::period>::value, "delay1 is milliseconds");
+            static_assert(std::is_same<decltype(mob.delay2.data)::period, std::chrono::milliseconds::period>::value, "delay2 is milliseconds");
+            if (bits[3].data[2].data.is_digit10())
+                bits[3].data[2].span.warning("delay1 lacks units; defaulting to ms"_s);
+            if (bits[3].data[3].data.is_digit10())
+                bits[3].data[3].span.warning("delay2 lacks units; defaulting to ms"_s);
+            TRY_EXTRACT(bits[3].data[2], mob.delay1);
+            TRY_EXTRACT(bits[3].data[3], mob.delay2);
+            if (bits[3].data.size() >= 5)
+            {
+                TRY_EXTRACT(bits[3].data[4], mob.event);
+            }
+            else
+            {
+                mob.event.data = NpcEvent();
+                mob.event.span = bits[3].data[3].span;
+                mob.event.span.end.column++;
+                mob.event.span.begin.column = mob.event.span.end.column;
+            }
+        }
+        else
+        {
+            mob.delay1.data = std::chrono::milliseconds::zero();
+            mob.delay1.span = bits[3].data[1].span;
+            mob.delay1.span.end.column++;
+            mob.delay1.span.begin.column = mob.delay1.span.end.column;
+            mob.delay2.data = std::chrono::milliseconds::zero();
+            mob.delay2.span = mob.delay1.span;
+            mob.event.data = NpcEvent();
+            mob.event.span = mob.delay1.span;
+        }
+        return Ok(std::move(mob));
+    }
+    static
+    Result<MapFlag> parse_mapflag(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits)
+    {
+        if (bits.size() != 3 && bits.size() != 4)
+        {
+            return Err(span.error_str("expect 3 or 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 1)
+        {
+            return Err(bits[0].span.error_str("expect 1 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "mapflag"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits.size() >= 4)
+        {
+            if (bits[3].data.size() != 1)
+            {
+                return Err(bits[3].span.error_str("expect 1 ,component,s"_s));
+            }
+        }
+
+        MapFlag mapflag;
+        mapflag.span = span;
+        TRY_EXTRACT(bits[0].data[0], mapflag.m);
+        mapflag.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], mapflag.name);
+        if (bits.size() >= 4)
+        {
+            TRY_EXTRACT(bits[3].data[0], mapflag.opt_extra);
+        }
+        else
+        {
+            mapflag.opt_extra.data = ""_s;
+            mapflag.opt_extra.span = bits[2].span;
+            mapflag.opt_extra.span.end.column++;
+            mapflag.opt_extra.span.begin.column = mapflag.opt_extra.span.end.column;
+        }
+        return Ok(std::move(mapflag));
+    }
+    static
+    Result<ScriptFunction> parse_script_function(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits, io::LineCharReader& lr)
+    {
+        //  ScriptFunction:     function|script|Fun Name{code}
+        if (bits.size() != 3)
+        {
+            return Err(span.error_str("expect 3 |component|s"_s));
+        }
+        assert(bits[0].data.size() == 1);
+        assert(bits[0].data[0].data == "function"_s);
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "script"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+
+        ScriptFunction script_function;
+        script_function.span = span;
+        script_function.key1_span = bits[0].data[0].span;
+        script_function.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], script_function.name);
+        // also expect '{' and parse real script
+        script_function.body = TRY(script::parse::parse_script_body(lr));
+        return Ok(std::move(script_function));
+    }
+    static
+    Result<ScriptNone> parse_script_none(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits, io::LineCharReader& lr)
+    {
+        //  ScriptNone:         -|script|script name|-1{code}
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        assert(bits[0].data.size() == 1);
+        assert(bits[0].data[0].data == "-"_s);
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "script"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        assert(bits[3].data[0].data == "-1"_s);
+        if (bits[3].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("last |component| should be just -1"_s));
+        }
+
+        ScriptNone script_none;
+        script_none.span = span;
+        script_none.key1_span = bits[0].data[0].span;
+        script_none.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], script_none.name);
+        script_none.key4_span = bits[3].data[0].span;
+        // also expect '{' and parse real script
+        script_none.body = TRY(script::parse::parse_script_body(lr));
+        return Ok(std::move(script_none));
+    }
+    static
+    Result<ScriptMapNone> parse_script_map_none(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits, io::LineCharReader& lr)
+    {
+        //  ScriptMapNone:      m,x,y,d|script|script name|-1{code}
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 4)
+        {
+            return Err(bits[0].span.error_str("expect 3 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "script"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits[3].data.size() != 1 || bits[3].data[0].data != "-1"_s)
+        {
+            return Err(bits[2].span.error_str("last |component| should be just -1"_s));
+        }
+
+        ScriptMapNone script_map_none;
+        script_map_none.span = span;
+        TRY_EXTRACT(bits[0].data[0], script_map_none.m);
+        TRY_EXTRACT(bits[0].data[1], script_map_none.x);
+        TRY_EXTRACT(bits[0].data[2], script_map_none.y);
+        TRY_EXTRACT(bits[0].data[3], script_map_none.d);
+        script_map_none.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], script_map_none.name);
+        script_map_none.key4_span = bits[3].data[0].span;
+        // also expect '{' and parse real script
+        script_map_none.body = TRY(script::parse::parse_script_body(lr));
+        return Ok(std::move(script_map_none));
+    }
+    static
+    Result<ScriptMap> parse_script_map(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits, io::LineCharReader& lr)
+    {
+        //  ScriptMap:          m,x,y,d|script|script name|class,xs,ys{code}
+        if (bits.size() != 4)
+        {
+            return Err(span.error_str("expect 4 |component|s"_s));
+        }
+        if (bits[0].data.size() != 4)
+        {
+            return Err(bits[0].span.error_str("expect 3 ,component,s"_s));
+        }
+        assert(bits[1].data.size() == 1);
+        assert(bits[1].data[0].data == "script"_s);
+        if (bits[2].data.size() != 1)
+        {
+            return Err(bits[2].span.error_str("expect 1 ,component,s"_s));
+        }
+        if (bits[3].data.size() != 3)
+        {
+            return Err(bits[3].span.error_str("expect 3 ,component,s"_s));
+        }
+
+        ScriptMap script_map;
+        script_map.span = span;
+        TRY_EXTRACT(bits[0].data[0], script_map.m);
+        TRY_EXTRACT(bits[0].data[1], script_map.x);
+        TRY_EXTRACT(bits[0].data[2], script_map.y);
+        TRY_EXTRACT(bits[0].data[3], script_map.d);
+        script_map.key_span = bits[1].data[0].span;
+        TRY_EXTRACT(bits[2].data[0], script_map.name);
+        TRY_EXTRACT(bits[3].data[0], script_map.npc_class);
+        TRY_EXTRACT(bits[3].data[1], script_map.xs);
+        TRY_EXTRACT(bits[3].data[2], script_map.ys);
+        // also expect '{' and parse real script
+        script_map.body = TRY(script::parse::parse_script_body(lr));
+        return Ok(std::move(script_map));
+    }
+    static
+    Result<std::unique_ptr<Script>> parse_script_any(io::LineSpan span, std::vector<Spanned<std::vector<Spanned<RString>>>>& bits, io::LineCharReader& lr)
+    {
+        std::unique_ptr<Script> rv;
+        // 4 cases:
+        //  ScriptFunction:     function|script|Fun Name{code}
+        //  ScriptNone:         -|script|script name|-1{code}
+        //  ScriptMapNone:      m,x,y,d|script|script name|-1{code}
+        //  ScriptMap:          m,x,y,d|script|script name|class,xs,ys{code}
+        if (bits[0].data[0].data == "function"_s)
+        {
+            rv = make_unique<ScriptFunction>(TRY(parse_script_function(span, bits, lr)));
+        }
+        else if (bits[0].data[0].data == "-"_s)
+        {
+            rv = make_unique<ScriptNone>(TRY(parse_script_none(span, bits, lr)));
+        }
+        else if (bits.size() >= 4 && bits[3].data[0].data == "-1"_s)
+        {
+            rv = make_unique<ScriptMapNone>(TRY(parse_script_map_none(span, bits, lr)));
+        }
+        else
+        {
+            rv = make_unique<ScriptMap>(TRY(parse_script_map(span, bits, lr)));
+        }
+        return Ok(std::move(rv));
+    }
+
+    /// Try to extract a top-level token
+    /// Return None at EOL, or Some(span)
+    /// This will alternate betweeen returning words and separators
+    static
+    Option<Spanned<RString>> lex(io::LineCharReader& lr, bool first)
+    {
+        io::LineChar c;
+        // at start of line, skip whitespace
+        if (first)
+        {
+            while (lr.get(c) && (/*c.ch() == ' ' ||*/ c.ch() == '\n'))
+            {
+                lr.adv();
+            }
+        }
+        // at end of file, end of line, or start of script, signal end
+        if (!lr.get(c) || c.ch() == '\n' || c.ch() == '{')
+        {
+            return None;
+        }
+        // separators are simple
+        if (c.ch() == '|' || c.ch() == ',')
+        {
+            Spanned<RString> bit;
+            bit.span.begin = c;
+            bit.span.end = c;
+            bit.data = RString(VString<1>(c.ch()));
+            lr.adv();
+            return Some(bit);
+        }
+        io::LineSpan span;
+        MString accum;
+        accum += c.ch();
+        span.begin = c;
+        span.end = c;
+        lr.adv();
+        if (c.ch() != '/')
+            first = false;
+
+        // if one-char token followed by an end-of-line or separator, stop
+        if (!lr.get(c) || c.ch() == '\n' || c.ch() == '{' || c.ch() == ',' || c.ch() == '|')
+        {
+            Spanned<RString> bit;
+            bit.span = span;
+            bit.data = RString(accum);
+            return Some(bit);
+        }
+
+        accum += c.ch();
+        span.end = c;
+        lr.adv();
+
+        // if first token on line, can get comment
+        if (first && c.ch() == '/')
+        {
+            while (lr.get(c) && c.ch() != '\n' && c.ch() != '{')
+            {
+                accum += c.ch();
+                span.end = c;
+                lr.adv();
+            }
+            Spanned<RString> bit;
+            bit.span = span;
+            bit.data = RString(accum);
+            return Some(bit);
+        }
+        // otherwise, collect until an end of line or separator
+        while (lr.get(c) && c.ch() != '\n' && c.ch() != '{' && c.ch() != ',' && c.ch() != '|')
+        {
+            accum += c.ch();
+            span.end = c;
+            lr.adv();
+        }
+        Spanned<RString> bit;
+        bit.span = span;
+        bit.data = RString(accum);
+        return Some(std::move(bit));
+    }
+
+    Result<std::unique_ptr<TopLevel>> parse_top(io::LineCharReader& in)
+    {
+        std::unique_ptr<TopLevel> rv;
+        Spanned<std::vector<Spanned<std::vector<Spanned<RString>>>>> bits;
+
+        {
+            Spanned<RString> mayc = TRY_UNWRAP(lex(in, true),
+                    {
+                        io::LineChar c;
+                        if (in.get(c) && c.ch() == '{')
+                        {
+                            return Err(c.error_str("Unexpected script open"_s));
+                        }
+                        return Ok(std::move(rv));
+                    });
+            if (mayc.data.startswith("//"_s))
+            {
+                Comment com;
+                com.comment = std::move(mayc.data);
+                com.span = std::move(mayc.span);
+                rv = make_unique<Comment>(std::move(com));
+                return Ok(std::move(rv));
+            }
+
+            if (mayc.data == "|"_s || mayc.data == ","_s)
+                return Err(mayc.span.error_str("Unexpected separator"_s));
+            bits.span = mayc.span;
+            bits.data.emplace_back();
+            bits.data.back().span = mayc.span;
+            bits.data.back().data.push_back(mayc);
+        }
+
+        while (true)
+        {
+            Spanned<RString> sep = TRY_UNWRAP(lex(in, false),
+                    break);
+            if (sep.data == "|"_s)
+            {
+                bits.data.emplace_back();
+            }
+            else if (sep.data != ","_s)
+            {
+                return Err(sep.span.error_str("Expected separator"_s));
+            }
+
+            Spanned<RString> word = TRY_UNWRAP(lex(in, false),
+                    return Err(sep.span.error_str("Expected word after separator"_s)));
+            if (bits.data.back().data.empty())
+                bits.data.back().span = word.span;
+            else
+                bits.data.back().span.end = word.span.end;
+            bits.span.end = word.span.end;
+            bits.data.back().data.push_back(std::move(word));
+        }
+
+        if (bits.data.size() < 2)
+            return Err(bits.span.error_str("Expected a line with |s in it"_s));
+        for (auto& bit : bits.data)
+        {
+            if (bit.data.empty())
+                return Err(bit.span.error_str("Empty components are not cool"_s));
+        }
+        if (bits.data[1].data.size() != 1)
+            return Err(bits.data[1].span.error_str("Expected a single word in type position"_s));
+        Spanned<RString>& w2 = bits.data[1].data[0];
+        if (w2.data == "warp"_s)
+        {
+            rv = make_unique<Warp>(TRY(parse_warp(bits.span, bits.data)));
+        }
+        else if (w2.data == "shop"_s)
+        {
+            rv = make_unique<Shop>(TRY(parse_shop(bits.span, bits.data)));
+        }
+        else if (w2.data == "monster"_s)
+        {
+            rv = make_unique<Monster>(TRY(parse_monster(bits.span, bits.data)));
+        }
+        else if (w2.data == "mapflag"_s)
+        {
+            rv = make_unique<MapFlag>(TRY(parse_mapflag(bits.span, bits.data)));
+        }
+        else if (w2.data == "script"_s)
+        {
+            rv = TRY_MOVE(parse_script_any(bits.span, bits.data, in));
+        }
+        else
+        {
+            return Err(w2.span.error_str("Unknown type"_s));
+        }
+        return Ok(std::move(rv));
+    }
+} // namespace parse
+} // namespace npc
+} // namespace tmwa
diff --git a/src/ast/npc.hpp b/src/ast/npc.hpp
new file mode 100644
index 0000000..e39a704
--- /dev/null
+++ b/src/ast/npc.hpp
@@ -0,0 +1,135 @@
+#pragma once
+//    ast/npc.hpp - Structure of non-player characters (including mobs).
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "fwd.hpp"
+
+#include "../compat/result.hpp"
+
+#include "../mmo/ids.hpp"
+#include "../mmo/strs.hpp"
+
+#include "../map/map.hpp"
+
+#include "script.hpp"
+
+
+namespace tmwa
+{
+namespace npc
+{
+namespace parse
+{
+    using io::Spanned;
+
+    struct TopLevel
+    {
+        io::LineSpan span;
+
+        virtual ~TopLevel();
+    };
+    struct Comment : TopLevel
+    {
+        RString comment;
+    };
+    struct Warp : TopLevel
+    {
+        Spanned<MapName> m;
+        Spanned<unsigned> x, y;
+        io::LineSpan key_span;
+        Spanned<NpcName> name;
+        Spanned<unsigned> xs, ys;
+        Spanned<MapName> to_m;
+        Spanned<unsigned> to_x, to_y;
+    };
+    struct ShopItem
+    {
+        Spanned<ItemName> name;
+        Spanned<int> value;
+    };
+    struct Shop : TopLevel
+    {
+        Spanned<MapName> m;
+        Spanned<unsigned> x, y;
+        Spanned<DIR> d;
+        io::LineSpan key_span;
+        Spanned<NpcName> name;
+        Spanned<Species> npc_class;
+        Spanned<std::vector<Spanned<ShopItem>>> items;
+    };
+    struct Monster : TopLevel
+    {
+        Spanned<MapName> m;
+        Spanned<unsigned> x, y;
+        Spanned<unsigned> xs, ys;
+        io::LineSpan key_span;
+        Spanned<MobName> name;
+        Spanned<Species> mob_class;
+        Spanned<unsigned> num;
+        Spanned<interval_t> delay1, delay2;
+        Spanned<NpcEvent> event;
+    };
+    struct MapFlag : TopLevel
+    {
+        Spanned<MapName> m;
+        io::LineSpan key_span;
+        // TODO should this extract all the way?
+        Spanned<RString> name;
+        Spanned<RString> opt_extra;
+    };
+    struct Script : TopLevel
+    {
+        io::LineSpan key_span;
+        // see src/script/parser.hpp
+        script::parse::ScriptBody body;
+    };
+    struct ScriptFunction : Script
+    {
+        io::LineSpan key1_span;
+        Spanned<RString> name;
+    };
+    struct ScriptNone : Script
+    {
+        io::LineSpan key1_span;
+        Spanned<NpcName> name;
+        io::LineSpan key4_span;
+    };
+    struct ScriptMapNone : Script
+    {
+        Spanned<MapName> m;
+        Spanned<unsigned> x, y;
+        Spanned<DIR> d;
+        Spanned<NpcName> name;
+        io::LineSpan key4_span;
+    };
+    struct ScriptMap : Script
+    {
+        Spanned<MapName> m;
+        Spanned<unsigned> x, y;
+        Spanned<DIR> d;
+        Spanned<NpcName> name;
+        Spanned<Species> npc_class;
+        Spanned<unsigned> xs, ys;
+    };
+    // other Script subclasses elsewhere? (for item and magic scripts)
+
+    Result<std::unique_ptr<TopLevel>> parse_top(io::LineCharReader& in);
+} // namespace parse
+} // namespace npc
+} // namespace tmwa
diff --git a/src/ast/npc_test.cpp b/src/ast/npc_test.cpp
new file mode 100644
index 0000000..2697351
--- /dev/null
+++ b/src/ast/npc_test.cpp
@@ -0,0 +1,569 @@
+#include "npc.hpp"
+//    ast/npc_test.cpp - Testsuite for npc parser.
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include <gtest/gtest.h>
+
+#include "../tests/fdhack.hpp"
+
+//#include "../poison.hpp"
+
+
+namespace tmwa
+{
+namespace npc
+{
+namespace parse
+{
+    static
+    io::FD string_pipe(ZString sz)
+    {
+        io::FD rfd, wfd;
+        if (-1 == io::FD::pipe(rfd, wfd))
+            return io::FD();
+        if (sz.size() != wfd.write(sz.c_str(), sz.size()))
+        {
+            rfd.close();
+            wfd.close();
+            return io::FD();
+        }
+        wfd.close();
+        return rfd;
+    }
+
+#define EXPECT_SPAN(span, bl,bc, el,ec)     \
+    ({                                      \
+        EXPECT_EQ((span).begin.line, bl);   \
+        EXPECT_EQ((span).begin.column, bc); \
+        EXPECT_EQ((span).end.line, el);     \
+        EXPECT_EQ((span).end.column, ec);   \
+    })
+
+    TEST(ast, eof)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            ""_s,
+            "\n"_s,
+            "\n\n"_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_EQ(res.get_success(), Some(std::unique_ptr<TopLevel>(nullptr)));
+        }
+    }
+    TEST(ast, comment)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //23456789
+            "// hello"_s,
+            "// hello\n "_s,
+            "// hello\nabc"_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,8);
+            auto p = dynamic_cast<Comment *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_EQ(p->comment, "// hello"_s);
+            }
+        }
+    }
+    TEST(ast, warp)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3         4
+            //234567890123456789012345678901234567890123456789
+            "map.gat,1,2|warp|To Other Map|3,4,other.gat,5,6"_s,
+            "map.gat,1,2|warp|To Other Map|3,4,other.gat,5,6\n"_s,
+            "map.gat,1,2|warp|To Other Map|3,4,other.gat,5,6{"_s,
+            // no optional fields in warp
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,47);
+            auto p = dynamic_cast<Warp *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                EXPECT_SPAN(p->x.span, 1,9, 1,9);
+                EXPECT_EQ(p->x.data, 1);
+                EXPECT_SPAN(p->y.span, 1,11, 1,11);
+                EXPECT_EQ(p->y.data, 2);
+                EXPECT_SPAN(p->key_span, 1,13, 1,16);
+                EXPECT_SPAN(p->name.span, 1,18, 1,29);
+                EXPECT_EQ(p->name.data, stringish<NpcName>("To Other Map"_s));
+                EXPECT_SPAN(p->xs.span, 1,31, 1,31);
+                EXPECT_EQ(p->xs.data, 3);
+                EXPECT_SPAN(p->ys.span, 1,33, 1,33);
+                EXPECT_EQ(p->ys.data, 4);
+                EXPECT_SPAN(p->to_m.span, 1,35, 1,43);
+                EXPECT_EQ(p->to_m.data, stringish<MapName>("other"_s));
+                EXPECT_SPAN(p->to_x.span, 1,45, 1,45);
+                EXPECT_EQ(p->to_x.data, 5);
+                EXPECT_SPAN(p->to_y.span, 1,47, 1,47);
+                EXPECT_EQ(p->to_y.data, 6);
+            }
+        }
+    }
+    TEST(ast, shop)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3         4         5
+            //2345678901234567890123456789012345678901234567890123456789
+            "map.gat,1,2,3|shop|Flower Shop|4,5:6,Named:7,Spaced :8"_s,
+            "map.gat,1,2,3|shop|Flower Shop|4,5:6,Named:7,Spaced :8\n"_s,
+            "map.gat,1,2,3|shop|Flower Shop|4,5:6,Named:7,Spaced :8{"_s,
+            // no optional fields in shop
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,54);
+            auto p = dynamic_cast<Shop *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                EXPECT_SPAN(p->x.span, 1,9, 1,9);
+                EXPECT_EQ(p->x.data, 1);
+                EXPECT_SPAN(p->y.span, 1,11, 1,11);
+                EXPECT_EQ(p->y.data, 2);
+                EXPECT_SPAN(p->d.span, 1,13, 1,13);
+                EXPECT_EQ(p->d.data, DIR::NW);
+                EXPECT_SPAN(p->key_span, 1,15, 1,18);
+                EXPECT_SPAN(p->name.span, 1,20, 1,30);
+                EXPECT_EQ(p->name.data, stringish<NpcName>("Flower Shop"_s));
+                EXPECT_SPAN(p->npc_class.span, 1,32, 1,32);
+                EXPECT_EQ(p->npc_class.data, wrap<Species>(4));
+                EXPECT_SPAN(p->items.span, 1,34, 1,54);
+                EXPECT_EQ(p->items.data.size(), 3);
+                EXPECT_SPAN(p->items.data[0].span, 1,34, 1,36);
+                EXPECT_SPAN(p->items.data[0].data.name.span, 1,34, 1,34);
+                EXPECT_EQ(p->items.data[0].data.name.data, stringish<ItemName>("5"_s));
+                EXPECT_SPAN(p->items.data[0].data.value.span, 1,36, 1,36);
+                EXPECT_EQ(p->items.data[0].data.value.data, 6);
+                EXPECT_SPAN(p->items.data[1].span, 1,38, 1,44);
+                EXPECT_SPAN(p->items.data[1].data.name.span, 1,38, 1,42);
+                EXPECT_EQ(p->items.data[1].data.name.data, stringish<ItemName>("Named"_s));
+                EXPECT_SPAN(p->items.data[1].data.value.span, 1,44, 1,44);
+                EXPECT_EQ(p->items.data[1].data.value.data, 7);
+                EXPECT_SPAN(p->items.data[2].span, 1,46, 1,54);
+                EXPECT_SPAN(p->items.data[2].data.name.span, 1,46, 1,52);
+                EXPECT_EQ(p->items.data[2].data.name.data, stringish<ItemName>("Spaced"_s));
+                EXPECT_SPAN(p->items.data[2].data.value.span, 1,54, 1,54);
+                EXPECT_EQ(p->items.data[2].data.value.data, 8);
+            }
+        }
+    }
+    TEST(ast, monster)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3         4         5         6
+            //23456789012345678901234567890123456789012345678901234567890123456789
+            "map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000,Npc::Event"_s,
+            "map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000,Npc::Event\n"_s,
+            "map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000,Npc::Event{"_s,
+            "Map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000"_s,
+            "Map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000\n"_s,
+            "Map.gat,1,2,3,4|monster|Feeping Creature|5,6,7000,8000{"_s,
+            "nap.gat,1,20304|monster|Feeping Creature|506,700008000"_s,
+            "nap.gat,1,20304|monster|Feeping Creature|506,700008000\n"_s,
+            "nap.gat,1,20304|monster|Feeping Creature|506,700008000{"_s,
+        };
+        for (auto input : inputs)
+        {
+            bool first = input.startswith('m');
+            bool second = input.startswith('M');
+            bool third = input.startswith('n');
+            assert(first + second + third == 1);
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,first?65:54);
+            auto p = dynamic_cast<Monster *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                if (first)
+                {
+                    EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                }
+                else if (second)
+                {
+                    EXPECT_EQ(p->m.data, stringish<MapName>("Map"_s));
+                }
+                else
+                {
+                    EXPECT_EQ(p->m.data, stringish<MapName>("nap"_s));
+                }
+                EXPECT_SPAN(p->x.span, 1,9, 1,9);
+                EXPECT_EQ(p->x.data, 1);
+                if (!third)
+                {
+                    EXPECT_SPAN(p->y.span, 1,11, 1,11);
+                    EXPECT_EQ(p->y.data, 2);
+                    EXPECT_SPAN(p->xs.span, 1,13, 1,13);
+                    EXPECT_EQ(p->xs.data, 3);
+                    EXPECT_SPAN(p->ys.span, 1,15, 1,15);
+                    EXPECT_EQ(p->ys.data, 4);
+                }
+                else
+                {
+                    EXPECT_SPAN(p->y.span, 1,11, 1,15);
+                    EXPECT_EQ(p->y.data, 20304);
+                    EXPECT_SPAN(p->xs.span, 1,16, 1,16);
+                    EXPECT_EQ(p->xs.data, 0);
+                    EXPECT_SPAN(p->ys.span, 1,16, 1,16);
+                    EXPECT_EQ(p->ys.data, 0);
+                }
+                EXPECT_SPAN(p->key_span, 1,17, 1,23);
+                EXPECT_SPAN(p->name.span, 1,25, 1,40);
+                EXPECT_EQ(p->name.data, stringish<MobName>("Feeping Creature"_s));
+                if (!third)
+                {
+                    EXPECT_SPAN(p->mob_class.span, 1,42, 1,42);
+                    EXPECT_EQ(p->mob_class.data, wrap<Species>(5));
+                    EXPECT_SPAN(p->num.span, 1,44, 1,44);
+                    EXPECT_EQ(p->num.data, 6);
+                    EXPECT_SPAN(p->delay1.span, 1,46, 1,49);
+                    EXPECT_EQ(p->delay1.data, 7_s);
+                    EXPECT_SPAN(p->delay2.span, 1,51, 1,54);
+                    EXPECT_EQ(p->delay2.data, 8_s);
+                }
+                else
+                {
+                    EXPECT_SPAN(p->mob_class.span, 1,42, 1,44);
+                    EXPECT_EQ(p->mob_class.data, wrap<Species>(506));
+                    EXPECT_SPAN(p->num.span, 1,46, 1,54);
+                    EXPECT_EQ(p->num.data, 700008000);
+                    EXPECT_SPAN(p->delay1.span, 1,55, 1,55);
+                    EXPECT_EQ(p->delay1.data, 0_s);
+                    EXPECT_SPAN(p->delay2.span, 1,55, 1,55);
+                    EXPECT_EQ(p->delay2.data, 0_s);
+                }
+                if (first)
+                {
+                    EXPECT_SPAN(p->event.span, 1,56, 1,65);
+                    EXPECT_EQ(p->event.data.npc, stringish<NpcName>("Npc"_s));
+                    EXPECT_EQ(p->event.data.label, stringish<ScriptLabel>("Event"_s));
+                }
+                else
+                {
+                    EXPECT_SPAN(p->event.span, 1,55, 1,55);
+                    EXPECT_EQ(p->event.data.npc, NpcName());
+                    EXPECT_EQ(p->event.data.label, ScriptLabel());
+                }
+            }
+        }
+    }
+    TEST(ast, mapflag)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3
+            //23456789012345678901234567890123456789
+            "map.gat|mapflag|flagname"_s,
+            "map.gat|mapflag|flagname\n"_s,
+            "map.gat|mapflag|flagname{"_s,
+            "Map.gat|mapflag|flagname|optval"_s,
+            "Map.gat|mapflag|flagname|optval\n"_s,
+            "Map.gat|mapflag|flagname|optval{"_s,
+        };
+        for (auto input : inputs)
+        {
+            bool second = input.startswith('M');
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,!second?24:31);
+            auto p = dynamic_cast<MapFlag *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                if (!second)
+                {
+                    EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                }
+                else
+                {
+                    EXPECT_EQ(p->m.data, stringish<MapName>("Map"_s));
+                }
+                EXPECT_SPAN(p->key_span, 1,9, 1,15);
+                EXPECT_SPAN(p->name.span, 1,17, 1,24);
+                EXPECT_EQ(p->name.data, "flagname"_s);
+                if (!second)
+                {
+                    EXPECT_SPAN(p->opt_extra.span, 1,25, 1,25);
+                    EXPECT_EQ(p->opt_extra.data, ""_s);
+                }
+                else
+                {
+                    EXPECT_SPAN(p->opt_extra.span, 1,26, 1,31);
+                    EXPECT_EQ(p->opt_extra.data, "optval"_s);
+                }
+            }
+        }
+    }
+
+    TEST(ast, scriptfun)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3
+            //23456789012345678901234567890123456789
+            "function|script|Fun Name{end;}"_s,
+            //                         123456
+            "function|script|Fun Name\n{end;}\n"_s,
+            //                           1234567
+            "function|script|Fun Name\n \n {end;} "_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,24);
+            auto p = dynamic_cast<ScriptFunction *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->key1_span, 1,1, 1,8);
+                EXPECT_SPAN(p->key_span, 1,10, 1,15);
+                EXPECT_SPAN(p->name.span, 1,17, 1,24);
+                EXPECT_EQ(p->name.data, "Fun Name"_s);
+                if (input.endswith('}'))
+                {
+                    EXPECT_SPAN(p->body.span, 1,25, 1,30);
+                }
+                else if (input.endswith('\n'))
+                {
+                    EXPECT_SPAN(p->body.span, 2,1, 2,6);
+                }
+                else if (input.endswith(' '))
+                {
+                    EXPECT_SPAN(p->body.span, 3,2, 3,7);
+                }
+                else
+                {
+                    FAIL();
+                }
+                EXPECT_EQ(p->body.braced_body, "{end;}"_s);
+            }
+        }
+    }
+    TEST(ast, scriptnone)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3
+            //23456789012345678901234567890123456789
+            "-|script|#config|-1{end;}"_s,
+            //                    123456
+            "-|script|#config|-1\n{end;}\n"_s,
+            //                      1234567
+            "-|script|#config|-1\n \n {end;} "_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,19);
+            auto p = dynamic_cast<ScriptNone *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->key1_span, 1,1, 1,1);
+                EXPECT_SPAN(p->key_span, 1,3, 1,8);
+                EXPECT_SPAN(p->name.span, 1,10, 1,16);
+                EXPECT_EQ(p->name.data, stringish<NpcName>("#config"_s));
+                EXPECT_SPAN(p->key4_span, 1,18, 1,19);
+                if (input.endswith('}'))
+                {
+                    EXPECT_SPAN(p->body.span, 1,20, 1,25);
+                }
+                else if (input.endswith('\n'))
+                {
+                    EXPECT_SPAN(p->body.span, 2,1, 2,6);
+                }
+                else if (input.endswith(' '))
+                {
+                    EXPECT_SPAN(p->body.span, 3,2, 3,7);
+                }
+                else
+                {
+                    FAIL();
+                }
+                EXPECT_EQ(p->body.braced_body, "{end;}"_s);
+            }
+        }
+    }
+    TEST(ast, scriptmapnone)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3
+            //23456789012345678901234567890123456789
+            "map.gat,1,2,3|script|Init|-1{end;}"_s,
+            "map.gat,1,2,3|script|Init|-1\n{end;}\n"_s,
+            "map.gat,1,2,3|script|Init|-1\n \n {end;} "_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,28);
+            auto p = dynamic_cast<ScriptMapNone *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                EXPECT_SPAN(p->x.span, 1,9, 1,9);
+                EXPECT_EQ(p->x.data, 1);
+                EXPECT_SPAN(p->y.span, 1,11, 1,11);
+                EXPECT_EQ(p->y.data, 2);
+                EXPECT_SPAN(p->d.span, 1,13, 1,13);
+                EXPECT_EQ(p->d.data, DIR::NW);
+                EXPECT_SPAN(p->key_span, 1,15, 1,20);
+                EXPECT_SPAN(p->name.span, 1,22, 1,25);
+                EXPECT_EQ(p->name.data, stringish<NpcName>("Init"_s));
+                EXPECT_SPAN(p->key4_span, 1,27, 1,28);
+                if (input.endswith('}'))
+                {
+                    EXPECT_SPAN(p->body.span, 1,29, 1,34);
+                }
+                else if (input.endswith('\n'))
+                {
+                    EXPECT_SPAN(p->body.span, 2,1, 2,6);
+                }
+                else if (input.endswith(' '))
+                {
+                    EXPECT_SPAN(p->body.span, 3,2, 3,7);
+                }
+                else
+                {
+                    FAIL();
+                }
+                EXPECT_EQ(p->body.braced_body, "{end;}"_s);
+            }
+        }
+    }
+    TEST(ast, scriptmap)
+    {
+        QuietFd q;
+        LString inputs[] =
+        {
+            //        1         2         3
+            //23456789012345678901234567890123456789
+            "map.gat,1,2,3|script|Asdf|4,5,6{end;}"_s,
+            "map.gat,1,2,3|script|Asdf|4,5,6\n{end;}\n"_s,
+            "map.gat,1,2,3|script|Asdf|4,5,6\n \n {end;} "_s,
+        };
+        for (auto input : inputs)
+        {
+            io::LineCharReader lr("<string>"_s, string_pipe(input));
+            auto res = parse_top(lr);
+            EXPECT_TRUE(res.get_success().is_some());
+            auto top = TRY_UNWRAP(std::move(res.get_success()), FAIL());
+            EXPECT_SPAN(top->span, 1,1, 1,31);
+            auto p = dynamic_cast<ScriptMap *>(top.get());
+            EXPECT_TRUE(p);
+            if (p)
+            {
+                EXPECT_SPAN(p->m.span, 1,1, 1,7);
+                EXPECT_EQ(p->m.data, stringish<MapName>("map"_s));
+                EXPECT_SPAN(p->x.span, 1,9, 1,9);
+                EXPECT_EQ(p->x.data, 1);
+                EXPECT_SPAN(p->y.span, 1,11, 1,11);
+                EXPECT_EQ(p->y.data, 2);
+                EXPECT_SPAN(p->d.span, 1,13, 1,13);
+                EXPECT_EQ(p->d.data, DIR::NW);
+                EXPECT_SPAN(p->key_span, 1,15, 1,20);
+                EXPECT_SPAN(p->name.span, 1,22, 1,25);
+                EXPECT_EQ(p->name.data, stringish<NpcName>("Asdf"_s));
+                EXPECT_SPAN(p->npc_class.span, 1,27, 1,27);
+                EXPECT_EQ(p->npc_class.data, wrap<Species>(4));
+                EXPECT_SPAN(p->xs.span, 1,29, 1,29);
+                EXPECT_EQ(p->xs.data, 5);
+                EXPECT_SPAN(p->ys.span, 1,31, 1,31);
+                EXPECT_EQ(p->ys.data, 6);
+                if (input.endswith('}'))
+                {
+                    EXPECT_SPAN(p->body.span, 1,32, 1,37);
+                }
+                else if (input.endswith('\n'))
+                {
+                    EXPECT_SPAN(p->body.span, 2,1, 2,6);
+                }
+                else if (input.endswith(' '))
+                {
+                    EXPECT_SPAN(p->body.span, 3,2, 3,7);
+                }
+                else
+                {
+                    FAIL();
+                }
+                EXPECT_EQ(p->body.braced_body, "{end;}"_s);
+            }
+        }
+    }
+} // namespace parse
+} // namespace npc
+} // namespace tmwa
diff --git a/src/ast/script.cpp b/src/ast/script.cpp
new file mode 100644
index 0000000..cc67224
--- /dev/null
+++ b/src/ast/script.cpp
@@ -0,0 +1,71 @@
+#include "script.hpp"
+//    ast/script.cpp - Structure of tmwa-script
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU Affero General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU Affero General Public License for more details.
+//
+//    You should have received a copy of the GNU Affero General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "../poison.hpp"
+
+
+namespace tmwa
+{
+namespace script
+{
+namespace parse
+{
+    Result<ScriptBody> parse_script_body(io::LineCharReader& lr)
+    {
+        io::LineSpan span;
+        io::LineChar c;
+        while (true)
+        {
+            if (!lr.get(c))
+            {
+                return Err("error: unexpected EOF before '{' in parse_script_body"_s);
+            }
+            if (c.ch() == ' ' || c.ch() == '\n')
+            {
+                lr.adv();
+                continue;
+            }
+            break;
+        }
+        if (c.ch() != '{')
+        {
+            return Err(c.error_str("expected opening '{'"_s));
+        }
+
+        MString accum;
+        accum += c.ch();
+        span.begin = c;
+        lr.adv();
+        while (true)
+        {
+            if (!lr.get(c))
+                return Err(c.error_str("unexpected EOF before '}' in parse_script_body"_s));
+            accum += c.ch();
+            span.end = c;
+            lr.adv();
+            if (c.ch() == '}')
+            {
+                return Ok(ScriptBody{RString(accum), std::move(span)});
+            }
+        }
+    }
+} // namespace parse
+} // namespace script
+} // namespace tmwa
diff --git a/src/ast/script.hpp b/src/ast/script.hpp
new file mode 100644
index 0000000..59e53f0
--- /dev/null
+++ b/src/ast/script.hpp
@@ -0,0 +1,94 @@
+#pragma once
+//    ast/script.hpp - Structure of tmwa-script
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU Affero General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU Affero General Public License for more details.
+//
+//    You should have received a copy of the GNU Affero General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "fwd.hpp"
+
+#include "../compat/result.hpp"
+
+#include "../io/line.hpp"
+
+
+namespace tmwa
+{
+namespace script
+{
+namespace parse
+{
+    using io::Spanned;
+
+    struct ScriptBody
+    {
+        RString braced_body;
+        io::LineSpan span;
+    };
+
+    Result<ScriptBody> parse_script_body(io::LineCharReader& lr);
+
+    /*
+    (-- First bare-body-chunk only allowed for npcs, items, magic, functions.
+        It is not allowed for events. Basically it's an implicit label at times.
+        Last normal-lines is only permitted on item and magic scripts. --)
+    { script-body }: "{" bare-body-chunk? body-chunk* normal-lines? "}"
+    body-chunk: (comment* labelname ":")+ bare-body-chunk
+    bare-body-chunk: normal-lines terminator-line
+    normal-lines: normal-line*
+    any-line: normal-line
+    any-line: terminator-line
+    normal-line: "if" "(" expr ")" any-line
+    normal-line: normal-command ((expr ",")* expr)? ";"
+    terminator-line: "menu" (expr, labelname)* expr, labelname ";"
+    terminator-line: "goto" labelname ";"
+    terminator-line: terminator ((expr ",")* expr)? ";"
+    terminator: "return"
+    terminator: "close"
+    terminator: "end"
+    terminator: "mapexit"
+    terminator: "shop"
+
+    expr: subexpr_-1
+    subexpr_N: ("+" | "-" | "!" | "~") subexpr_7
+    subexpr_N: simple-expr (op_M subexpr_M | "(" ((expr ",")+ expr)? ")")*            if N < M; function call only if N < 8 and preceding simple-expr (op sub)* is a known function
+    op_0: "||"
+    op_1: "&&"
+    op_2: "=="
+    op_2: "!="
+    op_2: ">="
+    op_2: ">"
+    op_2: "<="
+    op_2: "<"
+    op_3: "^"
+    op_4: "|"
+    op_5: "&"
+    op_5: ">>"
+    op_5: "<<"
+    op_6: "+"
+    op_6: "-"
+    op_7: "*"
+    op_7: "/"
+    op_7: "%"
+    simple-expr: "(" expr ")"
+    simple-expr: integer
+    simple-expr: string
+    simple-expr: variable ("[" expr "]")?
+    simple-expr: function // no longer command/label though
+    */
+} // namespace parse
+} // namespace script
+} // namespace tmwa
diff --git a/src/compat/result.hpp b/src/compat/result.hpp
new file mode 100644
index 0000000..f03c026
--- /dev/null
+++ b/src/compat/result.hpp
@@ -0,0 +1,78 @@
+#pragma once
+//    result.hpp - A possibly failed return value
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include "fwd.hpp"
+
+#include "../strings/rstring.hpp"
+
+#include "option.hpp"
+
+namespace tmwa
+{
+    namespace result
+    {
+        enum ResultMagicFlag { magic_flag };
+
+        template<class T>
+        class Result
+        {
+            Option<T> success;
+            RString failure;
+        public:
+            Result(ResultMagicFlag, T v)
+            : success(Some(std::move(v))), failure()
+            {}
+            Result(ResultMagicFlag, RString msg)
+            : success(None), failure(std::move(msg))
+            {}
+
+            bool is_ok() { return success.is_some(); }
+            bool is_err() { return !is_ok(); }
+
+            Option<T>& get_success() { return success; }
+            RString& get_failure() { return failure; }
+        };
+
+        template<class T>
+        Result<T> Ok(T v)
+        {
+            return Result<T>(magic_flag, std::move(v));
+        }
+
+        struct Err
+        {
+            RString message;
+            Err(RString m) : message(std::move(m)) {}
+
+            template<class T>
+            operator Result<T>()
+            {
+                return Result<T>(magic_flag, message);
+            }
+        };
+    } // namespace result
+    using result::Result;
+    using result::Ok;
+    using result::Err;
+
+#define TRY(r) ({ auto _res = r; TRY_UNWRAP(_res.get_success(), return ::tmwa::Err(_res.get_failure())); })
+    // TODO the existence of this as a separate macro is a bug.
+#define TRY_MOVE(r) ({ auto _res = r; TRY_UNWRAP(std::move(_res.get_success()), return ::tmwa::Err(_res.get_failure())); })
+} // namespace tmwa
diff --git a/src/compat/result_test.cpp b/src/compat/result_test.cpp
new file mode 100644
index 0000000..0fcc181
--- /dev/null
+++ b/src/compat/result_test.cpp
@@ -0,0 +1,79 @@
+#include "result.hpp"
+//    result_test.cpp - Testsuite for possibly failing return values
+//
+//    Copyright © 2014 Ben Longbons <b.r.longbons@gmail.com>
+//
+//    This file is part of The Mana World (Athena server)
+//
+//    This program is free software: you can redistribute it and/or modify
+//    it under the terms of the GNU General Public License as published by
+//    the Free Software Foundation, either version 3 of the License, or
+//    (at your option) any later version.
+//
+//    This program is distributed in the hope that it will be useful,
+//    but WITHOUT ANY WARRANTY; without even the implied warranty of
+//    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//    GNU General Public License for more details.
+//
+//    You should have received a copy of the GNU General Public License
+//    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#include <gtest/gtest.h>
+
+#include "../strings/literal.hpp"
+
+//#include "../poison.hpp"
+
+
+namespace tmwa
+{
+TEST(Result, inspect)
+{
+    struct Foo
+    {
+        int val;
+
+        Foo(int v) : val(v) {}
+        Foo(Foo&&) = default;
+        Foo(const Foo&) = delete;
+        Foo& operator = (Foo&&) = default;
+        Foo& operator = (const Foo&) = delete;
+
+        bool operator == (const Foo& other) const
+        {
+            return this->val == other.val;
+        }
+    };
+
+    Result<Foo> foo = Ok(Foo(1));
+    EXPECT_TRUE(foo.is_ok());
+    EXPECT_FALSE(foo.is_err());
+    EXPECT_EQ(foo.get_success(), Some(Foo(1)));
+    EXPECT_EQ(foo.get_failure(), ""_s);
+    foo = Err("oops"_s);
+    EXPECT_FALSE(foo.is_ok());
+    EXPECT_TRUE(foo.is_err());
+    EXPECT_EQ(foo.get_success(), None<Foo>());
+    EXPECT_EQ(foo.get_failure(), "oops"_s);
+}
+
+static
+Result<int> try_you(bool b)
+{
+    return b ? Ok(0) : Err("die"_s);
+}
+
+static
+Result<int> try_me(bool b)
+{
+    return Ok(TRY(try_you(b)) + 1);
+}
+
+TEST(Result, try)
+{
+    Result<int> t = try_me(true);
+    EXPECT_EQ(t.get_success(), Some(1));
+    Result<int> f = try_me(false);
+    EXPECT_EQ(f.get_failure(), "die"_s);
+}
+} // namespace tmwa
diff --git a/src/io/line.hpp b/src/io/line.hpp
index 8244c5e..5572e98 100644
--- a/src/io/line.hpp
+++ b/src/io/line.hpp
@@ -41,6 +41,9 @@ namespace io
         uint16_t line, column;
 
         AString message_str(ZString cat, ZString msg) const;
+        AString note_str(ZString msg) const { return message_str("note"_s, msg); }
+        AString warning_str(ZString msg) const { return message_str("warning"_s, msg); }
+        AString error_str(ZString msg) const { return message_str("error"_s, msg); }
         void message(ZString cat, ZString msg) const;
         void note(ZString msg) const { message("note"_s, msg); }
         void warning(ZString msg) const { message("warning"_s, msg); }
@@ -64,12 +67,22 @@ namespace io
         LineChar begin, end;
 
         AString message_str(ZString cat, ZString msg) const;
+        AString note_str(ZString msg) const { return message_str("note"_s, msg); }
+        AString warning_str(ZString msg) const { return message_str("warning"_s, msg); }
+        AString error_str(ZString msg) const { return message_str("error"_s, msg); }
         void message(ZString cat, ZString msg) const;
         void note(ZString msg) const { message("note"_s, msg); }
         void warning(ZString msg) const { message("warning"_s, msg); }
         void error(ZString msg) const { message("error"_s, msg); }
     };
 
+    template<class T>
+    struct Spanned
+    {
+        T data;
+        LineSpan span;
+    };
+
     class LineReader
     {
     protected:
diff --git a/src/io/line_test.cpp b/src/io/line_test.cpp
index edf60bd..46e7b57 100644
--- a/src/io/line_test.cpp
+++ b/src/io/line_test.cpp
@@ -23,6 +23,8 @@
 #include "../strings/astring.hpp"
 #include "../strings/zstring.hpp"
 
+#include "../tests/fdhack.hpp"
+
 #include "../poison.hpp"
 
 
@@ -57,6 +59,7 @@ TEST(io, line1)
 }
 TEST(io, line2)
 {
+    QuietFd q;
     io::LineReader lr("<string2>"_s, string_pipe("Hello\nWorld"_s));
     io::Line hi;
     EXPECT_TRUE(lr.read_line(hi));
@@ -73,6 +76,7 @@ TEST(io, line2)
 }
 TEST(io, line3)
 {
+    QuietFd q;
     io::LineReader lr("<string3>"_s, string_pipe("Hello\rWorld"_s));
     io::Line hi;
     EXPECT_TRUE(lr.read_line(hi));
@@ -89,6 +93,7 @@ TEST(io, line3)
 }
 TEST(io, line4)
 {
+    QuietFd q;
     io::LineReader lr("<string4>"_s, string_pipe("Hello\r\nWorld"_s));
     io::Line hi;
     EXPECT_TRUE(lr.read_line(hi));
@@ -105,6 +110,7 @@ TEST(io, line4)
 }
 TEST(io, line5)
 {
+    QuietFd q;
     io::LineReader lr("<string5>"_s, string_pipe("Hello\n\rWorld"_s));
     io::Line hi;
     EXPECT_TRUE(lr.read_line(hi));
@@ -175,6 +181,7 @@ TEST(io, linechar1)
 }
 TEST(io, linechar2)
 {
+    QuietFd q;
     io::LineCharReader lr("<stringchar2>"_s, string_pipe("Hi\nWu"_s));
     io::LineChar c;
     EXPECT_TRUE(lr.get(c));
@@ -223,6 +230,7 @@ TEST(io, linechar2)
 }
 TEST(io, linechar3)
 {
+    QuietFd q;
     io::LineCharReader lr("<stringchar3>"_s, string_pipe("Hi\rWu"_s));
     io::LineChar c;
     EXPECT_TRUE(lr.get(c));
@@ -271,6 +279,7 @@ TEST(io, linechar3)
 }
 TEST(io, linechar4)
 {
+    QuietFd q;
     io::LineCharReader lr("<stringchar4>"_s, string_pipe("Hi\r\nWu"_s));
     io::LineChar c;
     EXPECT_TRUE(lr.get(c));
@@ -319,6 +328,7 @@ TEST(io, linechar4)
 }
 TEST(io, linechar5)
 {
+    QuietFd q;
     io::LineCharReader lr("<stringchar5>"_s, string_pipe("Hi\n\rWu"_s));
     io::LineChar c;
     EXPECT_TRUE(lr.get(c));
@@ -402,17 +412,17 @@ TEST(io, linespan)
     }
     while (span.end.ch() != 'r');
 
-    EXPECT_EQ(span.begin.message_str("note"_s, "foo"_s),
+    EXPECT_EQ(span.begin.note_str("foo"_s),
             "<span>:1:5: note: foo\n"
             "Hello,\n"
             "    ^\n"_s
     );
-    EXPECT_EQ(span.end.message_str("warning"_s, "bar"_s),
+    EXPECT_EQ(span.end.warning_str("bar"_s),
             "<span>:2:3: warning: bar\n"
             "World!\n"
             "  ^\n"_s
     );
-    EXPECT_EQ(span.message_str("error"_s, "qux"_s),
+    EXPECT_EQ(span.error_str("qux"_s),
             "<span>:1:5: error: qux\n"
             "Hello,\n"
             "    ^~ ...\n"
diff --git a/src/io/read.cpp b/src/io/read.cpp
index 3ae5246..f3ed293 100644
--- a/src/io/read.cpp
+++ b/src/io/read.cpp
@@ -36,6 +36,8 @@ namespace io
 {
     ReadFile::ReadFile(FD f)
     : fd(f), start(0), end(0)
+    // only for debug-sanity
+    , buf{}
     {
     }
     ReadFile::ReadFile(ZString name)
diff --git a/src/io/read_test.cpp b/src/io/read_test.cpp
index 8fe84b7..e655eb1 100644
--- a/src/io/read_test.cpp
+++ b/src/io/read_test.cpp
@@ -24,6 +24,8 @@
 #include "../strings/zstring.hpp"
 #include "../strings/literal.hpp"
 
+#include "../tests/fdhack.hpp"
+
 #include "../poison.hpp"
 
 
@@ -47,6 +49,7 @@ io::FD string_pipe(ZString sz)
 
 TEST(io, read1)
 {
+    QuietFd q;
     io::ReadFile rf(string_pipe("Hello"_s));
     AString hi;
     EXPECT_TRUE(rf.getline(hi));
@@ -63,6 +66,7 @@ TEST(io, read2)
 }
 TEST(io, read3)
 {
+    QuietFd q;
     io::ReadFile rf(string_pipe("Hello\r"_s));
     AString hi;
     EXPECT_TRUE(rf.getline(hi));
@@ -71,6 +75,7 @@ TEST(io, read3)
 }
 TEST(io, read4)
 {
+    QuietFd q;
     io::ReadFile rf(string_pipe("Hello\r\n"_s));
     AString hi;
     EXPECT_TRUE(rf.getline(hi));
@@ -79,6 +84,7 @@ TEST(io, read4)
 }
 TEST(io, read5)
 {
+    QuietFd q;
     io::ReadFile rf(string_pipe("Hello\n\r"_s));
     AString hi;
     EXPECT_TRUE(rf.getline(hi));
diff --git a/src/io/write.cpp b/src/io/write.cpp
index 5359c7a..a98954b 100644
--- a/src/io/write.cpp
+++ b/src/io/write.cpp
@@ -37,6 +37,8 @@ namespace io
 {
     WriteFile::WriteFile(FD f, bool linebuffered)
     : fd(f), lb(linebuffered), buflen(0)
+    // only for debug-sanity
+    , buf{}
     {}
     WriteFile::WriteFile(ZString name, bool linebuffered)
     : fd(FD::open(name, O_WRONLY | O_CREAT | O_TRUNC, 0666)), lb(linebuffered), buflen(0)
diff --git a/src/map/clif.cpp b/src/map/clif.cpp
index b0cb541..a5b7278 100644
--- a/src/map/clif.cpp
+++ b/src/map/clif.cpp
@@ -5675,4 +5675,38 @@ void do_init_clif(void)
 {
     make_listen_port(map_port, SessionParsers{.func_parse= clif_parse, .func_delete= clif_delete});
 }
+
+bool extract(XString str, DIR *d)
+{
+    unsigned di;
+    if (extract(str, &di) && di < 8)
+    {
+        *d = static_cast<DIR>(di);
+        return true;
+    }
+    const struct
+    {
+        LString str;
+        DIR d;
+    } dirs[] =
+    {
+        {"S"_s, DIR::S},
+        {"SW"_s, DIR::SW},
+        {"W"_s, DIR::W},
+        {"NW"_s, DIR::NW},
+        {"N"_s, DIR::N},
+        {"NE"_s, DIR::NE},
+        {"E"_s, DIR::E},
+        {"SE"_s, DIR::SE},
+    };
+    for (auto& pair : dirs)
+    {
+        if (str == pair.str)
+        {
+            *d = pair.d;
+            return true;
+        }
+    }
+    return false;
+}
 } // namespace tmwa
diff --git a/src/map/clif.hpp b/src/map/clif.hpp
index 12ed633..9873592 100644
--- a/src/map/clif.hpp
+++ b/src/map/clif.hpp
@@ -189,4 +189,6 @@ int clif_GM_kick(dumb_ptr<map_session_data> sd, dumb_ptr<map_session_data> tsd,
 int clif_foreachclient(std::function<void(dumb_ptr<map_session_data>)>);
 
 void do_init_clif(void);
+
+bool extract(XString, DIR *);
 } // namespace tmwa
diff --git a/src/mmo/extract.cpp b/src/mmo/extract.cpp
index a480984..9123237 100644
--- a/src/mmo/extract.cpp
+++ b/src/mmo/extract.cpp
@@ -30,6 +30,9 @@
 #include "../poison.hpp"
 
 
+// TODO move this whole file to io/ or something.
+// It needs to be lower in the include hierarchy so it can be implemented
+// for library types. Also it should pass an io::LineSpan around.
 namespace tmwa
 {
 bool extract(XString str, XString *rv)
@@ -38,6 +41,12 @@ bool extract(XString str, XString *rv)
     return true;
 }
 
+bool extract(XString str, RString *rv)
+{
+    *rv = str;
+    return true;
+}
+
 bool extract(XString str, AString *rv)
 {
     *rv = str;
@@ -98,4 +107,174 @@ bool extract(XString str, CharName *out)
     }
     return false;
 }
+
+bool extract(XString str, std::chrono::nanoseconds *ns)
+{
+    std::chrono::nanoseconds::rep rep;
+    if (extract(str, &rep))
+    {
+        *ns = std::chrono::nanoseconds(rep);
+        return true;
+    }
+    if (str.endswith("ns"_s))
+    {
+        if (extract(str.xrslice_h("ns"_s.size()), &rep))
+        {
+            *ns = std::chrono::nanoseconds(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::microseconds bigger;
+    if (extract(str, &bigger))
+    {
+        *ns = bigger;
+        return *ns == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::microseconds *us)
+{
+    std::chrono::microseconds::rep rep;
+    if (extract(str, &rep))
+    {
+        *us = std::chrono::microseconds(rep);
+        return true;
+    }
+    if (str.endswith("us"_s))
+    {
+        if (extract(str.xrslice_h("us"_s.size()), &rep))
+        {
+            *us = std::chrono::microseconds(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::milliseconds bigger;
+    if (extract(str, &bigger))
+    {
+        *us = bigger;
+        return *us == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::milliseconds *ms)
+{
+    std::chrono::milliseconds::rep rep;
+    if (extract(str, &rep))
+    {
+        *ms = std::chrono::milliseconds(rep);
+        return true;
+    }
+    if (str.endswith("ms"_s))
+    {
+        if (extract(str.xrslice_h("ms"_s.size()), &rep))
+        {
+            *ms = std::chrono::milliseconds(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::seconds bigger;
+    if (extract(str, &bigger))
+    {
+        *ms = bigger;
+        return *ms == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::seconds *s)
+{
+    std::chrono::seconds::rep rep;
+    if (extract(str, &rep))
+    {
+        *s = std::chrono::seconds(rep);
+        return true;
+    }
+    if (str.endswith("s"_s))
+    {
+        if (extract(str.xrslice_h("s"_s.size()), &rep))
+        {
+            *s = std::chrono::seconds(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::minutes bigger;
+    if (extract(str, &bigger))
+    {
+        *s = bigger;
+        return *s == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::minutes *min)
+{
+    std::chrono::minutes::rep rep;
+    if (extract(str, &rep))
+    {
+        *min = std::chrono::minutes(rep);
+        return true;
+    }
+    if (str.endswith("min"_s))
+    {
+        if (extract(str.xrslice_h("min"_s.size()), &rep))
+        {
+            *min = std::chrono::minutes(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::hours bigger;
+    if (extract(str, &bigger))
+    {
+        *min = bigger;
+        return *min == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::hours *h)
+{
+    std::chrono::hours::rep rep;
+    if (extract(str, &rep))
+    {
+        *h = std::chrono::hours(rep);
+        return true;
+    }
+    if (str.endswith("h"_s))
+    {
+        if (extract(str.xrslice_h("h"_s.size()), &rep))
+        {
+            *h = std::chrono::hours(rep);
+            return true;
+        }
+        return false;
+    }
+    std::chrono::duration<int, std::ratio<60*60*24>> bigger;
+    if (extract(str, &bigger))
+    {
+        *h = bigger;
+        return *h == bigger;
+    }
+    return false;
+}
+bool extract(XString str, std::chrono::duration<int, std::ratio<60*60*24>> *d)
+{
+    std::chrono::duration<int, std::ratio<60*60*24>>::rep rep;
+    if (extract(str, &rep))
+    {
+        *d = std::chrono::duration<int, std::ratio<60*60*24>>(rep);
+        return true;
+    }
+    if (str.endswith("d"_s))
+    {
+        if (extract(str.xrslice_h("d"_s.size()), &rep))
+        {
+            *d = std::chrono::duration<int, std::ratio<60*60*24>>(rep);
+            return true;
+        }
+        return false;
+    }
+    return false;
+}
 } // namespace tmwa
diff --git a/src/mmo/extract.hpp b/src/mmo/extract.hpp
index ed2eb78..152d1b7 100644
--- a/src/mmo/extract.hpp
+++ b/src/mmo/extract.hpp
@@ -24,6 +24,7 @@
 #include <cstdlib>
 
 #include <algorithm>
+#include <chrono>
 #include <vector>
 
 #include "../ints/wrap.hpp"
@@ -93,7 +94,7 @@ bool extract_as_int(XString str, T *iv)
 }
 
 bool extract(XString str, XString *rv);
-
+bool extract(XString str, RString *rv);
 bool extract(XString str, AString *rv);
 
 template<uint8_t N>
@@ -228,4 +229,12 @@ bool extract(XString str, Wrapped<R> *w)
 {
     return extract(str, &w->_value);
 }
+
+bool extract(XString str, std::chrono::nanoseconds *ns);
+bool extract(XString str, std::chrono::microseconds *us);
+bool extract(XString str, std::chrono::milliseconds *ms);
+bool extract(XString str, std::chrono::seconds *s);
+bool extract(XString str, std::chrono::minutes *min);
+bool extract(XString str, std::chrono::hours *h);
+bool extract(XString str, std::chrono::duration<int, std::ratio<60*60*24>> *d);
 } // namespace tmwa
diff --git a/src/mmo/extract_test.cpp b/src/mmo/extract_test.cpp
index e6dc7b2..9c203ac 100644
--- a/src/mmo/extract_test.cpp
+++ b/src/mmo/extract_test.cpp
@@ -357,4 +357,92 @@ TEST(extract, mapname)
     EXPECT_TRUE(extract("abcdefghijklmno.gat"_s, &map));
     EXPECT_EQ(map, "abcdefghijklmno"_s);
 }
+
+TEST(extract, chrono)
+{
+    std::chrono::nanoseconds ns;
+    std::chrono::microseconds us;
+    std::chrono::milliseconds ms;
+    std::chrono::seconds s;
+    std::chrono::minutes min;
+    std::chrono::hours h;
+    std::chrono::duration<int, std::ratio<60*60*24>> d;
+
+    EXPECT_TRUE(extract("1"_s, &ns));
+    EXPECT_EQ(ns, 1_ns);
+    EXPECT_TRUE(extract("3ns"_s, &ns));
+    EXPECT_EQ(ns, 3_ns);
+    EXPECT_TRUE(extract("4us"_s, &ns));
+    EXPECT_EQ(ns, 4_us);
+    EXPECT_TRUE(extract("5ms"_s, &ns));
+    EXPECT_EQ(ns, 5_ms);
+    EXPECT_TRUE(extract("6s"_s, &ns));
+    EXPECT_EQ(ns, 6_s);
+    EXPECT_TRUE(extract("7min"_s, &ns));
+    EXPECT_EQ(ns, 7_min);
+    EXPECT_TRUE(extract("8h"_s, &ns));
+    EXPECT_EQ(ns, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &ns));
+    EXPECT_EQ(ns, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &us));
+    EXPECT_EQ(us, 1_us);
+    EXPECT_TRUE(extract("4us"_s, &us));
+    EXPECT_EQ(us, 4_us);
+    EXPECT_TRUE(extract("5ms"_s, &us));
+    EXPECT_EQ(us, 5_ms);
+    EXPECT_TRUE(extract("6s"_s, &us));
+    EXPECT_EQ(us, 6_s);
+    EXPECT_TRUE(extract("7min"_s, &us));
+    EXPECT_EQ(us, 7_min);
+    EXPECT_TRUE(extract("8h"_s, &us));
+    EXPECT_EQ(us, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &us));
+    EXPECT_EQ(us, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &ms));
+    EXPECT_EQ(ms, 1_ms);
+    EXPECT_TRUE(extract("5ms"_s, &ms));
+    EXPECT_EQ(ms, 5_ms);
+    EXPECT_TRUE(extract("6s"_s, &ms));
+    EXPECT_EQ(ms, 6_s);
+    EXPECT_TRUE(extract("7min"_s, &ms));
+    EXPECT_EQ(ms, 7_min);
+    EXPECT_TRUE(extract("8h"_s, &ms));
+    EXPECT_EQ(ms, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &ms));
+    EXPECT_EQ(ms, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &s));
+    EXPECT_EQ(s, 1_s);
+    EXPECT_TRUE(extract("6s"_s, &s));
+    EXPECT_EQ(s, 6_s);
+    EXPECT_TRUE(extract("7min"_s, &s));
+    EXPECT_EQ(s, 7_min);
+    EXPECT_TRUE(extract("8h"_s, &s));
+    EXPECT_EQ(s, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &s));
+    EXPECT_EQ(s, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &min));
+    EXPECT_EQ(min, 1_min);
+    EXPECT_TRUE(extract("7min"_s, &min));
+    EXPECT_EQ(min, 7_min);
+    EXPECT_TRUE(extract("8h"_s, &min));
+    EXPECT_EQ(min, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &min));
+    EXPECT_EQ(min, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &h));
+    EXPECT_EQ(h, 1_h);
+    EXPECT_TRUE(extract("8h"_s, &h));
+    EXPECT_EQ(h, 8_h);
+    EXPECT_TRUE(extract("9d"_s, &h));
+    EXPECT_EQ(h, 9_d);
+
+    EXPECT_TRUE(extract("1"_s, &d));
+    EXPECT_EQ(d, 1_d);
+    EXPECT_TRUE(extract("9d"_s, &d));
+    EXPECT_EQ(d, 9_d);
+}
 } // namespace tmwa
diff --git a/src/sexpr/lexer_test.cpp b/src/sexpr/lexer_test.cpp
index fdb47f2..5904894 100644
--- a/src/sexpr/lexer_test.cpp
+++ b/src/sexpr/lexer_test.cpp
@@ -76,21 +76,21 @@ TEST(sexpr, lexer)
     sexpr::Lexer lexer("<lexer-test1>"_s, string_pipe(" foo( ) 123\"\" \n"_s));
     EXPECT_EQ(lexer.peek(), sexpr::TOK_TOKEN);
     EXPECT_EQ(lexer.val_string(), "foo"_s);
-    EXPECT_EQ(lexer.span().message_str("error"_s, "test"_s),
+    EXPECT_EQ(lexer.span().error_str("test"_s),
             "<lexer-test1>:1:2: error: test\n"
             " foo( ) 123\"\" \n"
             " ^~~\n"_s
     );
     lexer.adv();
     EXPECT_EQ(lexer.peek(), sexpr::TOK_OPEN);
-    EXPECT_EQ(lexer.span().message_str("error"_s, "test"_s),
+    EXPECT_EQ(lexer.span().error_str("test"_s),
             "<lexer-test1>:1:5: error: test\n"
             " foo( ) 123\"\" \n"
             "    ^\n"_s
     );
     lexer.adv();
     EXPECT_EQ(lexer.peek(), sexpr::TOK_CLOSE);
-    EXPECT_EQ(lexer.span().message_str("error"_s, "test"_s),
+    EXPECT_EQ(lexer.span().error_str("test"_s),
             "<lexer-test1>:1:7: error: test\n"
             " foo( ) 123\"\" \n"
             "      ^\n"_s
@@ -98,7 +98,7 @@ TEST(sexpr, lexer)
     lexer.adv();
     EXPECT_EQ(lexer.peek(), sexpr::TOK_TOKEN);
     EXPECT_EQ(lexer.val_string(), "123"_s);
-    EXPECT_EQ(lexer.span().message_str("error"_s, "test"_s),
+    EXPECT_EQ(lexer.span().error_str("test"_s),
             "<lexer-test1>:1:9: error: test\n"
             " foo( ) 123\"\" \n"
             "        ^~~\n"_s
@@ -106,7 +106,7 @@ TEST(sexpr, lexer)
     lexer.adv();
     EXPECT_EQ(lexer.peek(), sexpr::TOK_STRING);
     EXPECT_EQ(lexer.val_string(), ""_s);
-    EXPECT_EQ(lexer.span().message_str("error"_s, "test"_s),
+    EXPECT_EQ(lexer.span().error_str("test"_s),
             "<lexer-test1>:1:12: error: test\n"
             " foo( ) 123\"\" \n"
             "           ^~\n"_s
diff --git a/src/sexpr/parser_test.cpp b/src/sexpr/parser_test.cpp
index 846d425..8619c15 100644
--- a/src/sexpr/parser_test.cpp
+++ b/src/sexpr/parser_test.cpp
@@ -20,6 +20,8 @@
 
 #include <gtest/gtest.h>
 
+#include "../tests/fdhack.hpp"
+
 #include "../poison.hpp"
 
 
@@ -90,6 +92,7 @@ TEST(sexpr, parselist)
 
 TEST(sexpr, parsebad)
 {
+    QuietFd q;
     for (LString bad : {
             "(\n"_s,
             ")\n"_s,
diff --git a/src/strings/rstring.cpp b/src/strings/rstring.cpp
index e74d1d5..5675935 100644
--- a/src/strings/rstring.cpp
+++ b/src/strings/rstring.cpp
@@ -58,13 +58,18 @@ namespace strings
     }
     RString& RString::operator = (const RString& r)
     {
-        // order important for self-assign
-        if (!r.maybe_end)
-            r.u.owned->count++;
-        if (!maybe_end && !u.owned->count--)
-            ::operator delete(u.owned);
-        u = r.u;
-        maybe_end = r.maybe_end;
+        // this turns out to be a win
+        // certain callers end up needing to do self-assignment a *lot*,
+        // leading to pointless ++,--s
+        if (this->u.owned != r.u.owned)
+        {
+            if (!r.maybe_end)
+                r.u.owned->count++;
+            if (!maybe_end && !u.owned->count--)
+                ::operator delete(u.owned);
+            u = r.u;
+            maybe_end = r.maybe_end;
+        }
         return *this;
     }
     RString& RString::operator = (RString&& r)
diff --git a/src/strings/strings2_test.cpp b/src/strings/strings2_test.cpp
index 8ac8482..8b91306 100644
--- a/src/strings/strings2_test.cpp
+++ b/src/strings/strings2_test.cpp
@@ -228,4 +228,13 @@ TEST(StringTests, rlong)
     EXPECT_EQ(&*r.begin(), &*r3.begin());
     EXPECT_EQ(&*a.begin(), &*a3.begin());
 }
+
+TEST(StringTest, rself)
+{
+    // force dynamic allocation; valgrind will check for memory errors
+    RString r = XString("foo bar baz"_s);
+    RString r2 = r;
+    r = r;
+    r = r2;
+}
 } // namespace tmwa
-- 
cgit v1.2.3-70-g09d2