Click here to Skip to main content
15,885,216 members
Articles / Programming Languages / XML
Article

DmRules - A helper library for running rules in .NET 3.0

Rate me:
Please Sign up or sign in to vote.
4.53/5 (25 votes)
10 Jul 2006CPOL14 min read 77.9K   410   64   18
A library that allows you to embed rules into your application. Written with Workflow Foundation libraries in .NET 3.0.

Introduction

As of yet, I have been unable to find a suitable rules engine to use in my .NET projects. Sure, there are the big guys out there like ILog and BizTalk that have full-blown rules engines. But, I needed something simple and free that I could embed into my .NET application.

.NET 3.0 introduces the Windows Workflow Foundation, which has a rules-based activity. When you get to a certain point in the workflow, you can run some rules on the object and use that to alter the object and/or decide which path to take next in the workflow. A workflow, basically, cannot exist without some sort of rules. What struck me about this rules implementation was that it used CodeDom for the conditions and actions. Recently, I wrote a CodeDom expression parser[^] that made my CodeDom programming life so much easier. So, why not put that parser to use?

I should mention upfront that this is by no means a real rules engine. The rules in .NET 3.0 do employ forward-chaining, which means that there's a lot that you don't have to do. But, the forward-chaining does not work across types. Getting this to work is the subject of future articles.

Demonstration

A demonstration of how this rules engine works can be found at the following link. This is a small game I made to illustrate how DmRules can be used in an application: Guess Word - A game written in .NET 3.0 using WPF and WWF[^].

Goals

I needed my embeddable rules engine to be able to handle certain things. To clarify: although I experimented with Prolog way back in college, I have no interest in implementing that here. I'm more interested in getting a core set of functionality that is useful to me in business applications. Also, it is my belief that business analysts that don't know how to code should never try to write rules. So, I will not try to make the rules-writing easy enough for them to do it! Here are some of the things I wanted my rules library to do:

  1. Handling calculations - When you update a number somewhere in one object, any other object that uses that number in a calculation should reevaluate. For example, I have a class that calculates how much money I need to allocate in my monthly budget. Part of that calculation is a budget for gas. Another object has the current gas price. When that price changes, the number for my total monthly budget should change as well. The gas price object should not care which objects depend on it, the update should be automatic.
  2. Assertions/auditing - When a value goes outside of an acceptable range, you may want to respond to that. You could raise some kind of error flag, or record the problem to a log.
  3. Forward-chaining - As mentioned above, if a change is made in object A, and object B depends on object A, then object B should be reevaluated. Basically, the system should be able to respond to changes without explicit coding to tell it what to update.
  4. Non-intrusive - Actual code changes to facilitate the rules should be trivial.
  5. Simple configuration - I would like to avoid having a really big, ugly XML file for defining the rules. A beginner programmer with no training on the rules engine should be able to read and understand the rules from the configuration XML.
  6. Adapting to changes - Let's face it, there are other ways to handle calculations and do assertions and auditing. Techniques like Aspect-Oriented Programming or simply using .NET Eventing are quite sufficient. But, what's really important is being able to change how the rules work. The conditions, calculations, or actions performed by a rule could change. A rules engine gives you the ability to handle these changes.

A Primer on Rules in Windows Workflow Foundation

Let's take a look at an example of running a rule. Microsoft has provided a basic rule editor that lets you write the conditions and actions in a GUI instead of using CodeDom. Instead of using that tool, we're going to write the CodeDom ourselves.

Here's our example class:

C#
public class Class1
{
   private int _Foo;
   private string _Bar;

   public int Foo
   {
      get { return _Foo; }
      set { _Foo = value; }
   }

   public string Bar
   {
      get { return _Bar; }
      set { _Bar = value; }
   }
}

Now, we can write a class to apply rules to Class1. First, we have to include the correct references. Add references to System.Workflow.Activities and System.Workflow.ComponentModel to your project. Now, we'll go step-by-step through the code. We don't need to include too many namespaces:

C#
using System.CodeDom;
using System.Workflow.Activities.Rules;

The core class that we're working with is called a RuleSet. When using Microsoft's tool, this object is serialized to the .rules file.

C#
RuleSet rs = new RuleSet();
Rule r = new Rule("Rule A");
rs.Rules.Add(r);

Rules in WF have a condition, a set of actions to perform if that condition is true, and a set of actions to perform if that condition is false. All of these are written in CodeDom. The condition we'll be testing for is if the property Foo is equal to 3.

C#
CodeThisReferenceExpression thisRef = new CodeThisReferenceExpression();
CodePropertyReferenceExpression fooRef = new 
   CodePropertyReferenceExpression(thisRef, "Foo");
CodeBinaryOperatorExpression fooCond = new CodeBinaryOperatorExpression();
fooCond.Left = fooRef;
fooCond.Operator = CodeBinaryOperatorType.ValueEquality;
fooCond.Right = new CodePrimitiveExpression(3);

As you can see, I'm not using my CodeDom expression parser library yet. That will come later. Anyways, we take our expression and apply it as the rule condition.

C#
r.Condition = new RuleExpressionCondition(fooCond);

Now, we need an action to perform, to at least signify that the rules work:

C#
CodePropertyReferenceExpression barRef = new 
   CodePropertyReferenceExpression(thisRef, "Bar");
CodeAssignStatement barThen = new CodeAssignStatement();
barThen.Left = barRef;
barThen.Right = new CodePrimitiveExpression("Gotcha");

This will change the value of Bar to "Gotcha" whenever the condition is true. We just have to add it as a "then" action:

C#
r.ThenActions.Add(new RuleStatementAction(barThen));

Now, we have to apply a validator to our rules. The validator bases itself off of a Type. Basically, it's going to verify that your rules are using valid properties/methods on Class1. This also brings me to an interesting point of discussion. A RuleSet is a set of rules that apply to one Type at a time. It makes perfect sense why it was done this way, but it does impose limitations on forward-chaining and such, that have to be dealt with in order to have a useful rules engine.

C#
RuleValidation rv = new RuleValidation(typeof(Class1), null);
rs.Validate(rv);

Now, we want to execute the rules. To test if the rule works correctly, we'll go in a loop increasing the value of Foo until it hits 3, then check the value of Bar:

C#
Class1 c = new Class1();
c.Bar = "Uh-uh";

for (int i = 0; i < 4; i++)
{
   c.Foo = i;
   RuleExecution rexec = new RuleExecution(rv, c);
   rs.Execute(rexec);
   Console.WriteLine("i: " + i + "\n\tFoo: " + c.Foo + "\n\tBar: " + c.Bar);
}

And the resulting output:

i: 0
    Foo: 0
    Bar: Uh-uh
i: 1
    Foo: 1
    Bar: Uh-uh
i: 2
    Foo: 2
    Bar: Uh-uh
i: 3
    Foo: 3
    Bar: Gotcha

This test is included in the source code package, so you can see for yourself. You can definitely see that this system has some potential. But, unless we want to be married to Microsoft's rules writing tool, we'll have to figure out a way to deal with CodeDom. Enter my CodeDom expression parser.

DmCodeDom - A CodeDom Helper Library

An expression parser like the one I showed here[^] is not enough to handle rules because it doesn't handle statements at all. So, I introduced another small helper library to help me write statements. This helper library only covers the basics:

  • Assignment
  • Declaration
  • If/Else - Not allowed in WF rules
  • For Loop - Also not allowed in WF rules
  • Expression Statements

To illustrate, the following simple loop has all of the above elements:

C#
for (int i = 0; i < 7; i++)
{
   if (i % 2 == 1)
      foo.Bar();
}

Here's how you would write it in CodeDom:

C#
CodeIterationStatement cis = new CodeIterationStatement();
CodeVariableReferenceExpression refI = new 
   CodeVariableReferenceExpression("i");
cis.InitStatement = new CodeVariableDeclarationStatement(
   typeof(int), "i", new CodePrimitiveExpression(0));
cis.IncrementStatement = new CodeAssignStatement(refI, 
   new CodeBinaryOperatorExpression(refI, 
   CodeBinaryOperatorType.Add,
   new CodePrimitiveExpression(1)));
cis.TestExpression = new CodeBinaryOperatorExpression(refI, 
   CodeBinaryOperatorType.LessThan, 
   new CodePrimitiveExpression(7));
CodeConditionStatement ccs = new CodeConditionStatement();
ccs.Condition = new 
   CodeBinaryOperatorExpression(new 
      CodeBinaryOperatorExpression(refI, 
         CodeBinaryOperatorType.Modulus,
         new CodePrimitiveExpression(2)),
      CodeBinaryOperatorType.ValueEquality,
      new CodePrimitiveExpression(1));
CodeMethodInvokeExpression cmie = new 
   CodeMethodInvokeExpression(new 
      CodeVariableReferenceExpression("foo"), "Bar", 
      new CodeExpression[0]);
ccs.TrueStatements.Add(cmie);
cis.Statements.Add(ccs);

Phew! CodeDom can get ugly fast. And, that's for a simple loop. We really have to do something about this. Here's how I do the exact same loop in my helper classes:

C#
ForLoop fl = new ForLoop();
fl.InitStmt = new Declaration("int", "i", "0");
fl.IncrStmt = new Assignment("i", "i + 1");
fl.Cond = "i < 7";
IfElse ie = new IfElse();
ie.Cond = "i % 2 == 1";
ie.TrueStmts.Add(new ExprStmt("foo.Bar()"));
fl.LoopStmts.Add(ie);

The reason I chose to use classes like ForLoop and Assignment is because I want to be able to serialize them to XML through the XmlSerializer class. Here's what the loop above would look like if serialized to XML:

XML
<DmCdStmt 
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
   xmlns:xsd="http://www.w3.org/2001/XMLSchema" 
   xsi:type="ForLoop" cond="i < 7">
   <InitStmt xsi:type="Declaration" type="int" varName="i" initExp="0" />
   <IncrStmt xsi:type="Assignment" left="i"
      right="i + 1" />
   <LoopStmts>
      <DmCdStmt xsi:type="IfElse" cond="i % 2 == 1">
         <TrueStmts>
            <DmCdStmt xsi:type="ExprStmt" expr="foo.Bar()" />
         </TrueStmts>
         <FalseStmts />
      </DmCdStmt>
   </LoopStmts>
</DmCdStmt>

I'm glad that in .NET 2.0, they finally fixed XmlSerializer such that it allows you to serialize and deserialize an abstract base class instead of only concrete classes. The XML probably could be a bit simpler, but the effort it would take to implement IXmlSerializable and do it myself is really not worth the gains.

Someday, I might actually turn DmCodeDom into a serious CodeDom helper library. If you'd be interested, post a comment at the bottom of this article (or give me a vote of 5 ).

Using the Code

The best way to show how this code works is to use an example. I'm going to have two simple classes: Order and OrderItem. Each order has a collection of items. The order has a total that totals up the cost of all the items. There are two situations that I want to handle:

  1. An item is added to or deleted from an order, and
  2. The price or quantity of an item is changed.

Here is the Order class:

C#
public class Order {
   private List<OrderItem> _Items = new List<OrderItem>();
   private int _NumItems = 0;
   private float _Total = 0f;
   public List<orderitem> Items {
      get { return _Items; }
   }
   public int NumItems {
      get { return _NumItems; }
   }
   public float Total {
      get { return _Total; }
   }
   internal void RecalculateTotal() {
      _Total = 0f;
      foreach (OrderItem oi in _Items)
         _Total += oi.Price * oi.Units;
   }
   public Order() {}
   public OrderItem NewItem() {
      return new OrderItem(this);
   }
   public OrderItem NewItem(float price, int units) {
      return new OrderItem(price, units, this);
   }
}</orderitem>

A pretty simple class, as you can see. It has a list of items, and reports a total cost for the order. One thing to make note of here is that there is a NumItems property that does not return Items.Count. This was done on purpose so that the rule could recognize that an item was added or deleted. There are, of course, other ways to do this, but I picked this method.

You can probably imagine what the OrderItem is going to look like. Here is that class:

C#
public class OrderItem : BaseObject {
   private float _Price = 0f;
   private int _Units = 0;
   private Order _Order = null;
   public float Price {
      get { return _Price; }
      set {
         if (value != _Price) {
            _Price = value; 
            MarkDirty();
         }
      }
   }
   public int Units {
      get { return _Units; }
      set { 
         if (value != _Units) {
            _Units = value;
            MarkDirty();
         }
      }
   }
   public Order Order {
      get { return _Order; }
   }
   internal OrderItem(Order order) {
      _Order = order;
   }
   internal OrderItem(float price, int units, Order order) {
      _Price = price;
      _Units = units;
      _Order = order;
   }
}

There are a few things to note about this class. The constructors are labeled as internal because I want them to be created by the Order class. Changes to Units or Price will mark the object as dirty. If you've never seen this before, it basically signals that the object was changed. This is good enough to inform the rules that they have to reevaluate. CSLA uses this technique to recognize that changes need to be saved to the database. The dirty flag is contained in the BaseObject class, which looks like this:

C#
public abstract class BaseObject {
   protected bool _IsDirty = false;
   public bool IsDirty {
      get { return _IsDirty; }
   }        
   public void MarkDirty() {
      _IsDirty = true;
   }
}

Creating and Running a Rule

The first rule we'll write is to handle a change in the number of items. This rule is applied to the Order class. We're going to write the whole thing out by hand first, and then I'll show later how you can put the rules into the application configuration. First, let's set up the rules:

C#
DmRule dr = new DmRule("this.NumItems != this.Items.Count",
   "Rule1",
   new DmCdStmt[] { 
      new ExprStmt("this.RecalculateTotal()"),
      new Assignment("this._NumItems", "this.Items.Count"),
      },
   new DmCdStmt[0]
   );
Parser parser = new Parser();
parser.Fields.Add("_NumItems");
DmRuleSet drs = new DmRuleSet();
drs.RuleTypes.Add(new DmRuleTypeSet(typeof(Order), new DmRule[] { dr }));
drs.Eval(parser);

So, we create a DmRule object to represent our rule. The rule's condition is comparing the NumItems property versus the Items.Count. If they don't match, then an item was added or deleted. If the rule evaluates to true, two things happen: the total is recalculated, and the NumItems is changed to reflect the current number of items. I would have preferred to recalculate the total in the rule action, but the rules library doesn't allow CodeIterationStatements.

After creating the rule, we match it up with a Type and add that to the DmRuleSet which will handle the rule execution. You may also notice that the expression parser needs to know that _NumItems is a field.

What we'll do is create an Order and then add an item to it. When we run the rule, it should figure out that an item was added and recalculate the total.

C#
Order o = new Order();
OrderItem oi = o.NewItem(2.3f, 2);

o.Items.Add(oi);

Console.WriteLine("Before:");
Console.WriteLine("  NumItems: " + o.NumItems);
Console.WriteLine("  Total: " + o.Total);

drs.RunRules(o);

Console.WriteLine("After:");
Console.WriteLine("  NumItems: " + o.NumItems);
Console.WriteLine("  Total: " + o.Total);

The resulting output is:

Before:
  NumItems: 0
  Total: 0
After:
  NumItems: 1
  Total: 4.6

Using RuleExec to Simplify Everything

Included in the library is a static class called RuleExec. This class takes care of some things automatically for you:

  • Rules can be specified in the application configuration instead of manually created.
  • Fields are automatically added to the expression parser by examining the type.
  • Running rules on an object can be done in one line of code.

For this part, we'll add another rule. This rule applies to the OrderItem class. When a change is made to either the price per unit or number of units, the item will be marked as dirty. When this happens, we want the order total to be recalculated. We'll add this rule to the existing rule on the Order class and put the whole thing in the App.config:

XML
<configuration>
   <configSections>
      <section 
         name="dmRulesConfig" 
         type="DmRules.Configuration.DmRulesConfigHandler, DmRules" 
         />
   </configSections>

   <dmRulesConfig>
   <DmRuleSet 
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
      xmlns:xsd="http://www.w3.org/2001/XMLSchema">
      <RuleTypes>
         <DmRuleTypeSet type="DmRules.TestHarness.Order, DmRules.TestHarness">
            <Rules>
               <DmRule cond="this.NumItems != this.Items.Count" name="Rule1">
                  <ThenStmts>
                     <DmCdStmt xsi:type="ExprStmt" expr="this.RecalculateTotal()" />
                     <DmCdStmt xsi:type="Assignment" left="this._NumItems" 
                        right="this.Items.Count" />
                  </ThenStmts>
                  <ElseStmts />
               </DmRule>
            </Rules>
         </DmRuleTypeSet>
         <DmRuleTypeSet type="DmRules.TestHarness.OrderItem, DmRules.TestHarness">
            <Rules>
               <DmRule cond="this.IsDirty" name="Rule1">
                  <ThenStmts>
                     <DmCdStmt xsi:type="ExprStmt" 
                        expr="this.Order.RecalculateTotal()" />
                     <DmCdStmt xsi:type="Assignment" left="this._IsDirty" 
                        right="false" />
                  </ThenStmts>
                  <ElseStmts />
               </DmRule>
            </Rules>
         </DmRuleTypeSet>
      </RuleTypes>
   </DmRuleSet>
   </dmRulesConfig>

</configuration>

From this example, you should be able to see how the XML is formatted. If you use the XmlSerializer regularly, then this should look familiar. The rule on OrderItem will check if the object is marked as dirty. If so, it tells the parent order to recalculate its total, and changes the dirty flag back to false. Notice also how the full type names are specified in this XML. Rules are always associated with a type. The rule names are also the same, but since they're on different types, it's OK.

RuleExec makes the job of coding a lot easier. We don't have to worry about running the parser ourselves or doing any of the set up work that was done in the previous section. The code below will create an Order, add an OrderItem to it, and change the number of units on that item:

C#
Order o = new Order();
OrderItem oi = o.NewItem(2.3f, 2);
o.Items.Add(oi);
RuleExec.ApplyRules(o);

oi.Units = 5;

Console.WriteLine("Before:");
Console.WriteLine("  Total: " + o.Total);

RuleExec.ApplyRules(oi);

Console.WriteLine("After:");
Console.WriteLine("  Total: " + o.Total);

Looks much easier, huh? Here is the resulting output of this code:

Before:
  Total: 4.6
After:
  Total: 11.5

Priorities and Halting

While working on my Guess Word game, I found that it is not guaranteed which order rules will be run in. They are not even run in the same order that they were entered into the RuleSet. Sometimes, it is necessary to run rules in a particular order. The solution to this is to use a priority.

XML
<DmRule cond="this.Foo == 5" name="Rule1" priority="2">
   <ThenStmts>
      <DmCdStmt xsi:type="Assignment" left="this.Bar" right="&quot;High&quot;" />
   </ThenStmt>
</DmRule>
<DmRule cond="this.Bar == &quot;High&quot;" name="Rule2" priority="1">
   <ThenStmts>
      <DmCdStmt xsi:type="Assignment" left="this.SomeProperty" right="true" />
   </ThenStmt>
</DmRule>

The priority attributes in this rule set indicate that Rule1 should run before Rule2. While it may seem that Rule1 will run before Rule2 anyway, this is not guaranteed. To guarantee the order, we specify the priority. In this particular case, forward-chaining would take over and recognize that we changed the Bar property. If Rule2 runs before Rule1, the worst that could happen is that the rule set is run twice. But, imagine you have several rules, all changing properties. Unless you use priorities, the rules could end up running many more times than they have to.

Another feature that is needed is halting. Rules in Workflow Foundation are allowed to have halt commands. These commands can be inserted anywhere into a set of then or else actions. What puzzles me about this is that conditions and loops are not allowed in then or else actions, so there's always a sequential flow. Anything after a halt would therefore be unreachable. With this in mind, it is safe to make halting an attribute on a rule.

XML
<DmRule cond="this.Foo &lt; 10" name="Rule1" haltAfterThen="true" 
        haltAfterElse="true" priority="1000">
   <ThenStmts>...</ThenStmts>
   <ElseStmts>...</ElseStmts>
</DmRule>

Obviously, halts are false by default, and do not need to be specified. The same goes for priority. By default, all priorities are zero, and the Workflow Foundation decides the order that they're run in.

Summary

The new Windows Workflow Foundation in .NET 3.0 adds a rules library that can be quite useful. There are times when you're writing an application that you feel that you'll need rules, but not workflow. That's the reason I've put together this library. Microsoft has included a rule-writing GUI that will end up writing a .rules file that pairs with your original source code. This is nice for the workflow environment, but the serialization is not in a human-readable format. Using my CodeDom expression parser, I have made it possible to write rules in the application configuration. My intention was to introduce people to this rules library, and to give them the ability to embed rules into their application.

What's Inside

Here's a listing of the files inside:

  • CodeDomExpParser - My CodeDom expression parser library.
  • DmCodeDom - CodeDom helper library.
    • Assignment.cs - Creates a CodeAssignmentStatement.
    • Declaration.cs - Creates a CodeVariableDeclarationStatement.
    • DmCdStmt.cs - Base class.
    • ExprStmt.cs - Creates a CodeExpressionStatement.
    • ForLoop.cs - Creates a CodeIterationStatement.
    • IfElse.cs - Creates a CodeConditionStatement.
  • DmCodeDom.TestHarness - NUnit test harness for the DmCodeDom library.
    • TestDmCd.cs - Contains code that was mentioned in the article.
  • DmRules
    • Configuration\DmRulesConfigHandler.cs - A configuration section handler to read the rules from the application configuration.
    • DmRule.cs - Represents a rule.
    • DmRuleSet.cs - Holds all the rules.
    • DmRuleTypeSet.cs - Pairs a rule with a type.
    • RuleExec.cs - Static helper class for running rules.
  • DmRules.TestHarness - NUnit test harness for DmRules.
    • BaseObject.cs - A base class that has the IsDirty property.
    • Order.cs - The Order class used above.
    • OrderItem.cs - The OrderItem class used above.
    • TestDmRules.cs - Contains code that was used in the article.

History

  • 0.1 : 2006-06-20 : Initial version
  • Updated the CodeDomExpParser library to work with .NET 2.0. Created the initial version of DmRules with the capability to run rules per type. The primary consideration for the future of the library is forward-chaining across types.

  • 0.2 : 2006-07-10 : Added priorities and halting
  • Rules are not guaranteed to run in any particular order. Assigning a priority can fix this problem. Sometimes, it is necessary to halt processing of other rules, a halt command has been added for this.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer Microsoft
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionHow do i assign a string to a variable. Pin
asp.anjan20-Nov-10 19:09
asp.anjan20-Nov-10 19:09 
AnswerRe: How do i assign a string to a variable. Pin
asp.anjan21-Nov-10 9:44
asp.anjan21-Nov-10 9:44 
QuestionDynamic Rule to validate Object Pin
dukekujo22-Apr-08 12:36
dukekujo22-Apr-08 12:36 
GeneralRe: Dynamic Rule to validate Object Pin
Dustin Metzgar22-Apr-08 18:18
Dustin Metzgar22-Apr-08 18:18 
GeneralRe: Dynamic Rule to validate Object Pin
dukekujo23-Apr-08 18:03
dukekujo23-Apr-08 18:03 
GeneralGraphic way to write rules Pin
Steve Strong17-Feb-08 16:42
Steve Strong17-Feb-08 16:42 
GeneralUse System.Workflow.Activities.Rules.Parser Pin
Deyan Petrov21-Sep-06 2:52
Deyan Petrov21-Sep-06 2:52 
GeneralRe: Use System.Workflow.Activities.Rules.Parser Pin
Dustin Metzgar8-Oct-06 3:04
Dustin Metzgar8-Oct-06 3:04 
GeneralRe: Use System.Workflow.Activities.Rules.Parser Pin
rodel.ros12-Dec-06 22:38
rodel.ros12-Dec-06 22:38 
GeneralToo Complicated Pin
Tim McCurdy27-Jun-06 2:51
Tim McCurdy27-Jun-06 2:51 
GeneralRe: Too Complicated Pin
Dustin Metzgar27-Jun-06 3:49
Dustin Metzgar27-Jun-06 3:49 
GeneralRe: Too Complicated Pin
Tim McCurdy27-Jun-06 7:51
Tim McCurdy27-Jun-06 7:51 
GeneralRe: Too Complicated Pin
WillemM11-Jul-06 1:30
WillemM11-Jul-06 1:30 
GeneralRe: Too Complicated Pin
88Keys31-Aug-06 8:26
88Keys31-Aug-06 8:26 
GeneralRe: Too Complicated Pin
Dustin Metzgar1-Sep-06 5:33
Dustin Metzgar1-Sep-06 5:33 
AnswerRe: Too Complicated Pin
88Keys1-Sep-06 13:01
88Keys1-Sep-06 13:01 
GeneralRe: Too Complicated Pin
Dustin Metzgar2-Sep-06 12:08
Dustin Metzgar2-Sep-06 12:08 
GeneralRe: Too Complicated Pin
Dustin Metzgar7-Sep-06 7:57
Dustin Metzgar7-Sep-06 7:57 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.