GameAttributes store persistent data like settings, statistics and high scores. They are documented in P3_SDK_V0.8\P3SampleApp\Documentation\html\ _persistent_data.html
We will explore settings and statistics. We will not cover high scores here because they have a different API.
Statistics are often called audits by older manufacturers.
In P3SampleApp, the game settings are defined in P3SASettingsMode. The settings built into the SDK are defined in the super class SettingsMode. P3SASettingsMode stores its GameAttributes in the database at <UserHome>\.multimorphic\P3\Data\<AppCode>\settings.db
In P3SampleApp, the statistics are defined in P3SAStatisticsMode. In reality, P3SampleApp does not define any game specific statistics but the class shows how to do it in comments. The statistics built into the SDK are defined in the super class StatisticsMode. P3SAStatisticsMode stores its GameAttributes in the database at <UserHome>\.multimorphic\P3\Data\<AppCode>\Statistics.db
A lot of the functionality is inherited by the base class DataManagerMode. As you can see, each DataManagerMode subclass stores its GameAttributes in a different database. This becomes important when talking about the database version later on.
The GameAttributes are defined in the method CreateDefaultAttrs() in P3SASettingsMode and P3SAStatisticsMode.
This is how the BallSaveTime setting is defined:
InitAttr(37, "BallSaveTime", "Ball Save Time", "Ball Save Time", "Service Menu/Settings/Gameplay/General", PRW, 15, 0, 20, 1, 15); |
A statistics is defined the same way. This is how a statistics counting the number of pop bumper activations would be defined:
InitAttr(5, "RightPops", "Right pop bumper activations", "Right pop bumper activations", "Service Menu/Statistics/" + Event + "/Mechs", GameAttributeOptions.ReadOnly, 0, 0, 0, 1, 0); |
The InitAttr() method is described in detail in a section below.
When a DataManagerMode is created, it compares its own version against the version stored in the database. If the versions are the same, the persisted data is used. If the database version is older, the persisted data is dropped and the database is recreated with the new GameAttribute definitions.
The version is specified in the subclass constructor:
public P3SASettingsMode (P3Controller controller, int priority) : base(controller, priority) { DataVersion = 41; DBVersion = 28; // more code not shown... } |
P3SAStatisticsMode works the same way with its own version and its own database. To simplify, we will only mention P3SASettingsMode.
The documentation for P3SASettingsMode can be found in
P3_SDK_V0.8/P3SampleApp/Documentation/html/class_multimorphic_1_1_p3_s_a_1_1_modes_1_1_data_1_1_p3_s_a_settings_mode.html
The interesting members related to versioning are: StoredVersion, DataVersion, DBVersion and InitAttr().
Unfortunately, the documentation contains inaccuracies that can be misleading. The following information was discovered experimentally instead.
The database version is stored in the database as a hidden game attribute.
StoredVersion is the value of the hidden game attribute when the app was started. This value is not updated when the database is recreated. This should have been a local variable. Ignore it completely.
The only purpose of DataVersion is to serve as the initial value of the hidden game attribute when not found in the database. This only happens when the database file is missing, either because the app is started for the first time ever or someone deleted the database for debugging.
If the DBVersion is higher than the StoredVersion, the database is rebuilt and the version stored in the database becomes DBVersion. Rebuilding the database means deleting all the contents and storing the latest game attribute declarations as is. In particular, this forgets the user chosen values and restores the GameAttributes to the factory settings.
It is a good idea to increase the DBVersion when deleting a GameAttribute. Recreating the database will remove the GameAttribute that no longer exists in the code. For most other cases, the GameAttributeCompareOptions can deal with the situation. Some developers might prefer to increase the DBVersion for every change in GameAttributes. This is attractive semantically, but also overwrites all user chosen values every time the version is bumped.
Setting the DataVersion lower than the DBVersion in the constructor is somewhat harmless. If the database does not exist, it will be created at version DataVersion and that empty database will be immediately rebuilt at version DBVersion.
Setting the DataVersion higher than DBVersion in the constructor like P3SampleApp does is rather puzzling. If the database does not exist, it will be created at version DataVersion (i.e. 41). Everything works, and the GameAttributes are added to the database. As we previously stated, increasing the DataVersion has no effect when the database already exists. Now consider what happens when the DBVersion is increased to 29. The StoredVersion is 41, which is higher than 29 so the persisted data is kept. The database will not be rebuilt. To force a rebuild, the DBVersion has to be increased to 42 or higher.
Unlike P3SampleApp, I recommend to set the DataVersion and DBVersion to the same value. Please send me a note if I’m missing something.
The design works well when the version is monotonically increasing. There is a situation when the version might be decreasing. The System Manager allows multiple versions of the same app to be installed at the same time. The user might choose to run an older version to compare against the latest version. If the older app has an older DBVersion in P3SASettingsMode, then the newer persisted data will be kept and only the GameAttributeCompareOptions will protect against incompatible changes.
A GameAttribute is created and merged with the existing persisted data by calling the method InitAttr() inherited from the DataManagerMode base class.
There are 16 variants of the method InitAttr():
protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, string avalue, string defaultValue, string[] rangeCode); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, bool avalue, bool defaultValue, string[] rangeCode, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, bool avalue, bool defaultValue, string[] rangeCode); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, bool avalue, bool defaultValue, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, bool avalue, bool defaultValue); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, float avalue, float minValue, float maxValue, float incrementValue, float defaultValue, string[] rangeCode); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, int avalue, int minValue, int maxValue, int incrementValue, int defaultValue, string[] rangeCode); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, long avalue, long minValue, long maxValue, long incrementValue, long defaultValue, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, long avalue, long minValue, long maxValue, long incrementValue, long defaultValue); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, float avalue, float minValue, float maxValue, float incrementValue, float defaultValue, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, float avalue, float minValue, float maxValue, float incrementValue, float defaultValue); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, int avalue, int minValue, int maxValue, int incrementValue, int defaultValue, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, string avalue, string defaultValue); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, string avalue); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, float avalue, float minValue, float maxValue, float incrementValue, float defaultValue, string[] rangeCode, GameAttributeCompareOptions cmpOptions); protected void InitAttr(int version, string item, string descr, string displayName, string classification, GameAttributeOptions options, int avalue, int minValue, int maxValue, int incrementValue, int defaultValue); |
Counter-intuitively, the version field of a GameAttribute is always ignored. It is not persisted either. You could pick any value like 1, but it might be more useful to set the version to the database version when that GameAttribute was introduced. This can serve as a form of documentation for the developer.
The classification is the path in the operator menus where the GameAttribute will appear. By choosing an existing path, this will add the GameAttribute to the existing menu. Choosing a path that does not exist will create new menus.
Here is a typical classification for a game settings: "Service Menu/Settings/Gameplay/General"
Here is a typical classification for a statistics: "Service Menu/Statistics/" + Event + "/Mechs"
In case you forgot, to access the service menu, open the coin door and press the launch button, then close the coin door. In the simulator, press and hold [ and press l (lowercase L), then navigate up with a, navigate down with ;, navigate back with s, select with l (lowercase L).
The definition of GameAttributeOptions is:
public enum GameAttributeOptions { None = 0, ReadWrite = 0, ReadOnly = 1, Hidden = 2, ShowTextValues = 4, ProfileOption = 8, Action = 16, Confirm = 32, PlayfieldModule = 64, Global = 128 } |
DataManagerMode defines some shorthands for some common GameAttributeOptions combinations:
protected const GameAttributeOptions RW = GameAttributeOptions.None; protected const GameAttributeOptions TV = GameAttributeOptions.ShowTextValues; protected const GameAttributeOptions PRW = GameAttributeOptions.ProfileOption; protected const GameAttributeOptions PTV = GameAttributeOptions.ShowTextValues | GameAttributeOptions.ProfileOption; |
A ReadOnly GameAttribute cannot be modified in the service menu, though it can be modified programmatically. This is suitable for a statistics for example.
A Hidden GameAttribute does not appear in the service menu. This is often done to hardcode the value of a built-in settings. There is an example how to do this in the SDK guide (See P3_SDK_V0.8/P3SampleApp/Documentation/html/_persistent_data.html#CustomizingDefaultGameAttributes). The trick is to retrieve the GameAttribute, set the value and add GameAttributeOptions.Hidden to the GameAttribute options.
The ShowTextValues option shows text values in the rangeCode array instead of the bool or int value. Notice the values don’t have to be integers from 0 to the length of the array - 1. For example, the Difficulty below can be 10, 20 or 30 but displays Easy, Medium or Hard. Unfortunately, I was not able to make the rangeCode work with a float value.
InitAttr(38, "UserProfileEditingEnabled", "Allow user to edit this profile", "Allow user to edit this profile", "Service Menu/Settings/General/Profiles", PTV, true, true, new string[] {"No", "Yes"});
InitAttr(38, "Difficulty", "Difficulty", "Difficulty", "Service Menu/Settings/Gameplay/General", PTV, 10, 10, 30, 10, 10, new string[3] { "Easy", "Medium", "Hard" });
//ShowTextValues does not work with float values
//InitAttr(38, "Initial Fuel", "Initial Fuel", "Initial Fuel", "Service Menu/Settings/Gameplay/General" PTV, 0.5F, 0F, 1F, 0.5F, 0.5F, new string[3] { "Empty", "Half", "Full" });
The ProfileOption means the GameAttribute is stored in the profile, therefore it can differ between players if they use different profiles. This can be used to adjust the difficulty depending on the players’ skill. Some options like pricing and replay thresholds don’t make sense in a profile, therefore choosing whether a GameAttribute belongs in the profile really depends on what the GameAttribute does. Caching of GameAttributes can be tricky and error prone. It is simpler to retrieve the value every time it is needed. This will automatically take care of GameAttributes that can differ per profile.
An Action GameAttribute is not editable. When attempting to edit the value, the event Evt_SaveGameAttribute is sent as if the value was already selected. The intent is for the GameAttribute to trigger the execution of some code in the application.
Enabling the Confirm option tells the service menu to ask for confirmation before truly setting the GameAttribute value.
The PlayfieldModule option documents the settings is module specific. This does not appear to have specific behavior attached to it.
The Global option documents the settings is global to the whole platform. This does not appear to have specific behavior either.
GameAttributeCompareOptions
The definition of GameAttributeCompareOptions is:
public enum GameAttributeCompareOptions { CompareNone = 1, CompareAll = 2, CompareAllExceptValue = 4, UpdateValueIfAnyChange = 8, UpdateValueIfDefaultChanged = 16 } |
For the InitAttr() variants that don’t take a GameAttributeCompareOptions argument, the default is GameAttributeCompareOptions.CompareAllExceptValue.
Notice there is no variant with GameAttributeCompareOptions for an int value. The compiler will choose the variant for a long value instead.
The GameAttribute has a current value and a defaultValue. The defaultValue is not treated specially in the operator menu. Its purpose is more subtle and will be described later.
InitAttr() creates the GameAttribute and merges it with the existing persistent data. The guiding principle is to always adopt the latest definition while preserving the user chosen value when possible.
If this is the first time the GameAttribute is created, it does not exist in the persistent data, therefore it can be persisted as is.
If the GameAttribute was created previously, it already exists in the persistent data, therefore it must be merged before the new definition can be persisted. Of course, if nothing changed, there is nothing to merge, but in general, the definition of the GameAttribute could have changed, forcing a merge.
The GameAttribute is compared to the persistent data according to the GameAttributeCompareOptions of the newly created GameAttribute. The GameAttributeCompareOptions of the persisted data is not relevant.
The options are:
Let’s go through all the cases.
In practice, the default CompareAllExceptValue is a good choice because it accepts upgrades to the GameAttribute definitions while preserving the user chosen value.
The option UpdateValueIfAnyChange is useful when at least one previously valid value is no longer valid in the new GameAttribute definition. When combined with CompareAll or CompareAllExceptValue, the persisted data will compare unequal, forcing the value to be overwritten by the defaultValue. This ensures the new persisted value is always valid for the new GameAttribute definition. This may overwrite a valid value but it is much simpler than the alternative to write custom code. An even simpler solution is to increase the DBVersion to force a database rebuild.
The option UpdateValueIfDefaultChanged might be better suited for high score GameAttributes. Let me know if I’m missing another obvious use case.
The options UpdateValueIfAnyChange or UpdateValueIfDefaultChanged do nothing when not combined with CompareAll or CompareAllExceptValue. I believe there is a problem with this statement in P3SASettingsMode:
attr.compareOptions = GameAttributeCompareOptions.UpdateValueIfDefaultChanged;
The statement should probably be:
attr.compareOptions = GameAttributeCompareOptions.CompareAllExceptValue | GameAttributeCompareOptions.UpdateValueIfDefaultChanged;
There is only one place that I know of where the SDK makes use of the defaultValue. Going in the service menu and assigning Yes to “Service Menu/Statistics/ResetScores“ will reset the high score GameAttributes to their default values.
The defaultValue could also be used to return to “factory settings”, but I don’t know if that option is even available to the user.
I suggest to always set the value equal to the defaultValue in InitAttr() and forget about the defaultValue afterwards. The defaultValue is relevant only with UpdateValueIfAnyChange or UpdateValueIfDefaultChanged, and if the value is overwritten with the defaultValue, that strategy keeps the value the same and is therefore equivalent to persisting the new GameAttribute as is.