Table of Contents

Views from data

Most views bind to a view-model written in C#. A DataViewModel is a view-model with no code of its own: its values, rows and commands are set by name, as data, and it reports what the player does as events. It suits a window whose contents are decided somewhere other than the game's compiled code: a server, a mod, a script, or content downloaded after release.

It comes in two forms:

  • A view file the game ships, driven by data. The view is trusted like any other.
  • Markup from elsewhere, which the game did not ship and doesn't trust. It is checked and held to limits before anything is built from it (see Markup from elsewhere).

What BSGameLib does, and what the game does

BSGameLib.UI builds the view, holds its state, applies changes to it, and tells the game what the player did. That is all. Where the markup and the changes come from, and where the player's actions go, is the game's to decide: a network connection, a file, a mod's script. Nothing in BSGameLib fetches markup or carries changes, and BSGameLib.Server knows nothing about these views. Over a network sketches what a game builds to drive one from a server.

A view from a file

using BSGameLib.UI;
using BSGameLib.UI.DataViews;

public static class Backpack
{
  public static DataViewModel Open(IViewManager views)
  {
    var backpack = new DataViewModel(views, Path.Combine(AppContext.BaseDirectory, "Views", "Backpack.view.xml"));
    views.SetView(backpack);

    backpack.Apply(DataViewChange.Set("Gold", 120));
    backpack.Apply(DataViewChange.Command("Use"));
    backpack.Apply(DataViewChange.Collection("Items", "Id", new List<IReadOnlyDictionary<string, object>>
    {
      new Dictionary<string, object> { ["Id"] = 1, ["Name"] = "Potion", ["Count"] = 3 },
      new Dictionary<string, object> { ["Id"] = 2, ["Name"] = "Rope", ["Count"] = 1 },
    }));

    backpack.CommandInvoked += (_, e) => Console.WriteLine($"{e.Command} on item {e.RowKey}");
    return backpack;
  }
}

The view binds to those names as it would to a view-model's properties:

<View>
  <StackLayout Orientation="Vertical" VerticalSpacing="8">
    <Label Id="gold" Size="{200,24}" Text="{Binding Gold}" />
    <Repeater Id="items" ItemsSource="{Binding Items}">
      <ItemTemplate>
        <StackLayout Orientation="Horizontal" HorizontalSpacing="8">
          <Label Size="{120,24}" Text="{Binding Name}" />
          <Label Size="{40,24}" Text="{Binding Count}" />
          <Button Size="{60,24}" Text="Use" Command="Use" />
        </StackLayout>
      </ItemTemplate>
    </Repeater>
  </StackLayout>
</View>

Changing the state

Each change is made with one of DataViewChange's static methods, and Apply(DataViewChange) applies it:

Method What it does
Set(name, value) Sets one value, such as Gold.
Collection(name, keyColumn, rows) Replaces a collection of rows. Each row is found by the value of keyColumn.
Row(collection, key, values) Changes the row with that key, or adds it at the end.
Remove(collection, key) Takes out the row with that key.
Command(name, enabled) Offers a command, or takes it back when enabled is false.

A changed value is raised as a change, so anything bound to it updates. A changed collection replaces the old one, so a repeater rebuilds its rows. A row's values are by name: a row's Name is {Binding Name} in the row's template. A collection holds at most MostRows rows, which defaults to 1,000; rows past that are not taken.

What the player does

The view reports what the player does through two events:

Event Arguments When it fires
CommandInvoked Command, Collection, RowKey An offered command was pressed. Collection and RowKey are set when it was pressed on a row.
ValueChanged Name, Value The player changed a value that is bound two ways, such as a text box.

A command does nothing unless it was offered with DataViewChange.Command, and enabled: false takes it back. A button bound to an offered command passes its row's key with it, so the button on a row knows which row it is.

Markup from elsewhere

Markup the game didn't ship is given as text, with limits on its size:

using BSGameLib.UI;
using BSGameLib.UI.Builder;
using BSGameLib.UI.DataViews;

public static class ModWindows
{
  public static DataViewModel Create(IViewManager views, string markup, IViewPictures pictures)
  {
    var limits = new UntrustedViewLimits(MostCharacters: 64 * 1024, MostDepth: 16, MostElements: 500);

    return new DataViewModel(views, markup, "backpack", limits)
    {
      MostRows = 200,
      Scope = new ViewScope
      {
        LocalizationPrefix = "backpack.",
        Pictures = pictures,
        Templates = new[] { Path.Combine(AppContext.BaseDirectory, "Theme.template.xml") },
      },
    };
  }
}

The name, "backpack" here, is only for the log.

What it may not do

Such markup is checked before anything is built from it, and refused if it:

  • Declares <Resources> or <References>. Those would replace the game's own colours, styles and textures by their ids. It uses the game's theme as it is.
  • Uses a TextureFile. It shows pictures only through its scope (see below).
  • Names a font by a path. A font is one of the game's fonts, named by a plain name with no folder, no .., and nothing absolute.
  • Is larger than its UntrustedViewLimits. The defaults are 262,144 characters of markup, 32 levels of nesting, and 2,000 elements, controls and their parts included.

A view that is refused, or that doesn't match the schema, fails to build. The reasons come out of SetView as an AggregateException, so catch it and tell the player the window couldn't open; the rest of the game is not affected.

What the game gives it

Besides its values and collections, only what its ViewScope gives reaches it:

  • Pictures, through Pictures: an IViewPictures that turns the name in an Image's Source into a texture. The markup names pictures, never files, and an unknown name draws nothing.
  • Text, through LocalizationPrefix: a prefix the game chooses for this view's {Localize} keys, so its text can live in its own part of the language files. Each key is looked up with the prefix first, then as written, so it can use the game's own keys too. With the prefix backpack., {Localize use} finds backpack.use if the language file has it, and use if not.
  • Theme, through Templates: game templates the view is built with, by absolute path, as if it referenced them. That is how it gets the game's styles, so Style="theme.button" works in it.

Over a network

To have a server open and drive a view on a client, the game carries the markup, the changes and the player's actions in packets of its own. A minimal set, with BSGameLib.Networking:

using BSGameLib.Networking;

// Server to client: open a view, set a value, offer a command.
public class OpenViewPacket : IDataPacket
{
  public string View { get; set; }
  public string Markup { get; set; }
}

public class SetViewValuePacket : IDataPacket
{
  public string View { get; set; }
  public string Name { get; set; }
  public string Value { get; set; }
}

public class OfferViewCommandPacket : IDataPacket
{
  public string View { get; set; }
  public string Command { get; set; }
  public bool Enabled { get; set; }
}

// Client to server: the player pressed a command.
public class ViewCommandPacket : IDataPacket
{
  public string View { get; set; }
  public string Command { get; set; }
  public string RowKey { get; set; }
}

On the client, each packet's handler turns it into a DataViewModel or a DataViewChange, and CommandInvoked sends a ViewCommandPacket back. Collections and rows need packets of their own in the same way. On the server, the game decides what a command means, and refuses one for a view it didn't open or a command it didn't offer.

Using it safely

  • Treat everything the view reports as a request. Check a CommandInvoked or ValueChanged where the view's contents come from before acting on it; with a server, on the server.
  • Keep the limits as low as the window needs. A larger window is more work to build and draw.
  • Catch the AggregateException from SetView.
  • Give the view only the pictures and templates it should use.