Vortex Studio SDK Advanced - Fields And Extensions



Advance Field Types

Listed below are a list of special fields. The all derived from VxData::FieldBase.

Container - VxData::Container

Grouping together several data to make a data structure can be done easily with a VxData::Container.

The preferred way to create a custom container is by deriving from VxData::Container, and having fields as class members.

The constructor of the user container should be similar the VxData::Container constructor.

class FluidInteractionContainer : public VxData::Container 
{ 
    public: 
        FluidInteractionContainer ( const Vx::VxID& name, VxData::Container* parent ) 
            : VxData::Container(name, parent) 
            , centerOfBuoyancy(Vx::VxVector3(), "CenterOfBuoyancy", this) 
            , displacedVolume(0.0, "DisplacedVolume", this) 
            , dragTranslationCoefficient(Vx::VxVector3(), "DragCoefficient", this) 
            , liftTranslationCoefficient(Vx::VxVector3(), "LiftCoefficient", this) 
            , dragTorqueScale(Vx::VxVector3(1.0,1.0,1.0), "DragScale", this) 
        { } 

        VxData::Field<Vx::VxVector3> centerOfBuoyancy; 
        VxData::Field<Vx::VxReal> displacedVolume; 
        VxData::Field<Vx::VxVector3> dragTranslationCoefficient; 
        VxData::Field<Vx::VxVector3> liftTranslationCoefficient; 
        VxData::Field<Vx::VxVector3> dragTorqueScale; 
};
VxData::Container are especially useful when several extensions share the same data structure.
For exemple, an IDynamic extension could have a container as an output, while a IGraphic extension has the same container has an input.
When the two are connected, the data flow is guaranteed and all values are correctly transferred.

A container can also be put inside a container.

class Person : public VxData::Container 
{ 
    public: 
        enum eSexe { kMale, kFemale, kUndecided }; 

        Person( const Vx::VxID& name, VxData::Container* parent ) 
            : VxData::Container(name, parent) 
            , name("Name", this) 
            , age(0.0, "Age", this) 
            , sex(kUndecided, "Sex", this) 
        { } 

        VxData::Field<std::string> name; 
        VxData::Field<unsigned int> age; 
        VxData::Field<eSexe> sex; 
}; 

class Conference: public VxData::Container 
{ 
    public: 
        Conference( const Vx::VxID& name, VxData::Container* parent ) 
            : VxData::Container(name, parent) 
            , presenter("Speaker", this) 
            , subject("", "Subject", this) 
        { } 

        Person presenter; 
        VxData::Field<std::string> subject; 
};

Defining a custom container in an extension is very similar to adding a simple Field<T>.

 // in header file 
class MyExtension : public VxSim::IExtension, public VxGraphics::IGraphic 
{ 
    public: 
        MyExtension(VxSim::VxExtension* proxy); 

        Conference parameterMainEvent; 
} 

// in cpp file 
MyExtension::MyExtension(VxSim::VxExtension* proxy) 
    : VxSim::IExtension(proxy, 0) 
    , VxGraphics::IGraphic(proxy) 
    , parameterMainEvent("MainEvent", &proxy->getParameterContainer()) 
{ }

 
When deriving from VxData::Container, keep in mind it is simply a data container, functionalities and member functions should, in general, be in the extension.

Adding Fields to a Container at Runtime

In some cases, it might be necessary to add or remove fields at run-times.

This can be done with Container::addField. The added fields are serialized correctly in the file and recreated automatically when the file is loaded again, there is no need to manually recreate them.

class CommunicationExtension : public VxSim::IExtension 
{ 
    VxData::Container inputChannels; 
    
    public: CommunicationExtension(VxSim::VxExtension* proxy) 
        : inputChannels("Channels", &proxy->getInputContainer()) 
    { } 

    [...] 

    void initChannels(const std::vector<std::string>& channels) 
    { 
        // clear the channels that were previously set (this will delete the fields) 
        inputChannels.clear(); 

        for ( auto it = channels.begin(); it != channels.end(); ++it ) 
        { 
            // add an input channel 
            std::string name = *it; 

            // add a Field<double> with the proper name
            inputChannels.addField(name, VxData::Types::Type_VxReal); 

        } 
    } 
}; 

Array of Fields - VxData::FieldArray<T>

FieldArray<T> is a typed field container that is related to a std::vector<> or Vx::VxArray<>, its API is similar. It is a template class and the template class argument must be a VxData field class, including derived Container and other FieldArray. They are used when you want a variable number of similar items. VxData::FieldArray<T> is designed to as an array of elements of the same type T, where T is a field class.

class MaterialLayer : public VxData::Container 
{ 
    public: 
        MaterialLayer(const Vx::VxID& name, VxData::Container* parent); 

        VxData::Field<std::string> name; 
        VxData::Field<VxGraphics::Texture*> texture; 
        VxData::Field<VxGraphics::Texture*> maskTexture; 
        VxData::Field<Vx::VxReal> factor; 
        VxData::Field<Vx::VxColor> color; 
}; 

class Material : public VxSim::IObject, public VxGraphics::IGraphic 
{ 
     public: 
         [...] 

         VxData::FieldArray<MaterialLayer> emissionLayers; 

         [...] 
}; 

void Material::_initTextures() 
{ 
    if ( emissionLayers.empty() ) 
    // VxData::FieldArray<> API is similar to std::vector 
    { 
        emissionLayers.resize(1); 
    } 

    for ( auto it = emissionLayers.begin(); it != emissionLayers.end(); ++it ) 
    { 
        if ( (*it)->texture.getValue() != nullptr ) 
        { 
            _initTexture((*it)->texture.getValue()); 
        } 
    } 
} 
VxData::FieldArray­ is ideal for organizing complex parameters. Its flexibility has the drawback of a significant performance cost that increases as the FieldArray gets bigger : it should be smaller than 50 elements. Iterating through a FieldArray is costly. For large data, VxData::Vector is preferred.

Vector - VxData::Vector<T>

VxData::FieldArray<T> is not fast. It is very generic but it does not offer the performance necessary for data that are constantly changing.

This is the main reason for the creation of a field class that has a VxArray<T> as internal data. Since it is contiguous in memory, access and update is much faster.

VxData::Vector<T> cannot be used with all types. Only types having a value are supported.

NumericalVxData::Vector<int>
VxData::Vector<unsigned int>
VxData::Vector<short>
VxData::Vector<unsigned short>
VxData::Vector<double>same as VxData::Vector<Vx::VxReal>
VxData::Vector<bool>
Compound ValuesVxData::Vector<Math::Vector2>
VxData::Vector<Math::Vector3>same as VxData::Field<Vx::VxVector3>
VxData::Vector<Math::Vector4>
VxData::Vector<Math::Matrix44>
VxData::Vector<Vx::VxTransform >
VxData::Vector<VxMath::Quaternion>same as VxData::Field<Vx::VxQuaternion>
VxData::Vector<Vx::VxColor>
StringVxData::Vector<std::string>
VxData::Vector<Vx::VxFilename>

VxData::Vector<T> are designed to handle large amount of data with little overhead.

For example, a large number of moving particles could be handled with two VxData::Vector<T>.

class ParticlesDataContainer : public VxData::Container { public: ParticlesDataContainer( const Vx::VxID& name, VxData::Container* parent ) : VxData::Container(name, parent) , radii("Radii", this) , positions("Positions", this) { } VxData::Vector< double > radii; VxData::Vector< Vx::VxVector3 > positions; ];  

Setting up the content of a VxData::Vector<T> is most efficient when directly copying a VxArray<T>. This greatly limits the number of observers called.

class ParticleGenerator : public VxSim::IDynamics, public VxSim::IExtension { public: ParticleGenerator(VxSim::VxExtension* proxy) : VxSim::IDynamics(proxy) , VxSim::IExtension(proxy) , outputParticles("Particles", &proxy->getOutputContainer()) { } [...] ParticlesDataContainer outputParticles; [...] void postStep() { Vx::VxArray<double> r; // create temporary data Vx::VxArray<VxMath::Vector3> p; auto generatedParticles = _generateParticles(); r.reserve(generatedParticles.size()); p.reserve(generatedParticels.size()); for( auto it = generatedParticles.begin(); it != generatedParticles.end(); ++it ) { r.push_back(it->radius); p.push_back(it->position); } outputParticles.radii = r; // copy temporary data in the outputs outputParticles.positions = p; } }; 

Accessing the data in a VxData::Vector<T> is similar to using a Vx::VxArray<T> or a std::vector<T>.

class ParticleGenerator : public VxGraphics::IGraphic, public VxSim::IExtension { public: ParticleGenerator(VxSim::VxExtension* proxy) : VxGraphics::IGraphic(proxy) , VxSim::IExtension(proxy) , inputParticles("Particles", &proxy->getInputContainer()) { } [...] ParticlesDataContainer inputParticles; [...] void onUpdate() { size_t index = 0; for( auto itRadius = inputParticles.radii.begin(); it != inputParticles.radii.end(); ++it, ++index ) { _drawParticle(*itRadius, inputParticles.positions[index]); } } }; 
VxData::Vector has a very limited UI in the Editor. It can be connected, but the values can not be seen or set.

Field Observers

Observers can be used to be notified about a change in a field.

Any field can have an observer. Fields notify their observers whenever they change.

The observer function takes a const FieldBase& argument. The object passed to the observer is the object that is being modified.

The API to add an observer is common to all field types.

VxData::DelegateConnection VxData::FieldBase::addObserver(std::function<void(const FieldBase&)> callback) const;  

 

Observers notifications are immediate; they will be called in the same thread that changed the field. This will bypass the scheduling of the modules.

A better practice is to set a flag and do the processing later.An observers should not change other fields, i.e., neither change a value, nor add or remove a field in a container. It may cascade a series of connections and observers especially when extensions are interconnected.

While in Application Mode simulating, fields in extensions that are not active will not be called. An active extension is an extension that has a module that manages it.

Single Value Field - VxData::Field<T> Observers

The observers are called when the value inside the field has been changed. This is the basic behavior.

For pointer values (e.g., VxData::Field<VxDynamics::Constraint*>), the observer will be called when the object (here, a Constraint*) is changed. It will also be called if it has been deleted. In that case, the value inside the field will become nullptr.

class MyExtension : public VxSim::IExtension { public: VxData::Field<double> inputVelocity; private: bool mVelocityHasChanged; public: MyExtension(VxSim::VxExtension* proxy) : VxSim::IExtension(proxy, kCurrentVersion) , inputVelocity(0, "Speed", &proxy-­>getInputContainer()) , mVelocityHasChanged(false) { inputVelocity.addObserver([this](const VxData::FieldBase& field) { mVelocityHasChanged = true; }); } }; 
Using lambda functions while defining observers makes it compact and clear.

With single valued fields, Field<T>, the observer can receive directly the modified field or value.

// observer receives the field inputVelocity.addObserver([this](const VxData::Field<double>& field) { mVelocityHasChanged = true; }); // observer receives the value inputVelocity.addObserver([this](double velocity) { mVelocityHasChanged = true; }); 
When loading a file, observers will be called when the values are set in the extension. When restoring a keyframe, observers will be called, too. This means that, in most cases, onLoaded and onStateRestore do not need to be implemented since observers will do the job automatically.

Multiple Value Field - VxData::Container and VxData::FieldArray<T> Observers

For multiple value fields, the observer will be called when a value is added, removed, or changed.

The FieldBase received by the observer will help to find out what triggered the observer. If a container is received, it means a field was added to or removed from this container.

If a Field<T> is received, it means that particular field has changed value.

Putting an observer on a container or FieldArray<T> is a simple way of being notified of all changes inside.

When a container has a sub-container, an observer on the top container will receive the notifications of all the changes inside, no matter how deep the field that changed.

class MaterialLayer : public VxData::Container { public: MaterialLayer(const Vx::VxID& name, VxData::Container* parent); VxData::Field<std::string> name; VxData::Field<VxGraphics::Texture*> texture; VxData::Field<VxGraphics::Texture*> maskTexture; VxData::Field<Vx::VxReal> factor; VxData::Field<Vx::VxColor> color; }; class Material : public VxSim::IObject, public VxGraphics::IGraphic { public: [...] VxData::FieldArray<MaterialLayer> emissionLayers; [...] }; Material::Material(VxSim::VxObject* proxy) : VxSim::IObject(proxy) , VxGraphics::IGraphic(proxy) , emissionLayers("Emission", &proxy->getParameterContainer()) { emissionLayers.addObserver([this](const VxData::FieldBase& field) { if ( &field == &emissionLayers ) // an item was added or removed from the FieldArray<> { _initaliseLayer(&emissionLayers); } else ( field->getType() == VxData::Types::Type_DataContainer ) { // an item was added or removed from one of the layer : problem! } else // some value was changed { _updateLayer(field.getParent()); } }); } 
FieldBase::getType() can be used to rapidly check which item was modified.
Observers on FieldArray(and to a lesser extent, container) may be notified several times for a single operation, especially when adding or removing fields.

VxData::Vector­<T> Observers

As with all other fields, the observers are called when the value changes. There are two types of notifications for Vector. When the field received by the observer has getType() of VxData::Types::Type_Vector, the whole vector has changed, including a possible change in size. If the FieldBase type is VxData::Types::Type_VectorItem, only one value in the vector has changed.

class HeightField : public VxGraphics::IGraphic, public VxSim::IExtension { public: VxData::Vector<double> inputHeights; [...] HeightField(VxSim::VxExtension* proxy) : VxGraphics::IGraphic(proxy) , VxSim::IExtension(proxy) , inputHeights("HeightField", &proxy->getInputContainer()) , mRedrawParticles(false) , mUpdateAllParticles(false) { inputHeights.addObserver([this](const VxData::FieldBase& field) { if ( field.getType() == VxData::Types::Type_Vector ) { // the whole vector has changed _resetHeight() } else if ( field.getType() == VxData::Types::Type_VectorItem ) { // one value was changed const VxData::VectorItemBase& item = dynamic_cast<const VxData::VectorItemBase&>(field); _updateCell(item->getIndex()); } }); } [...] }; 

Observer management - VxData::DelegateConnection

Observers are callbacks that are added to the FieldBase object. When an extension adds an observer to its own field, management is trivial since the object observed and the object receiving the notification are deleted at the same time. However, if an extension adds an observer to another extension, care must be taken because one of the objects may be deleted before the other. This management is done with the object returned by addObserver.

class HeightField : public VxSim::IDynamic, public VxSim::IExtension { private: VxData::DelegateConnection mObserverConnection; void _onPartMoved(); public: VxData::Field<VxDynamics::Part*> parameterPart; [...] virtual ~HeightField() { // make sure that when the object is deleted, the observer will never be called mObserverConnection.destroy(); } HeightField(VxSim::VxExtension* proxy) : VxSim::IDynamic(proxy) , VxSim::IExtension(proxy) , parameterPart("TargetPart", &proxy->getParameterContainer()) , mObserverConnection() { parameterPart.addObserver([this](const VxData::FieldBase& field) { // this is called when the parameter TargetPart is changed mObserverConnection.destroy(); // the part we are observing has changed if ( parameterPart.getValue() != nullptr ) { // we have a part to observe VxDynamics::Part* part = parameterPart.getValue(); mObserverConnection = part->outputWorldTransform.addObserver(std::bind(&HeightField::_onPartMoved, this)); } }); } }; 
DelegateConnection can be used to temporarily deactivate, reactivate, or permanently deactivate an observer.

Field Serialization

Serialization is supported for all extensions. By default, all inputs, outputs and parameters are saved to a file, and are restored when the file is loaded. When a file is loaded and the values are set in the extension, all observers will be called normally, so if your extension works correctly when a value is changed during simulation, no special code needs to be done to support serialization.

If your extension has private data that is not in the fields, you will need to override these methods:

  • IExtension::onSaving must not change anything in the extensions; its purpose is to inject the private data needed to be able to load the extension correctly.
  • IExtension::onLoading is the main versioning function. It is called with the data read directly from the file. The data can be set directly to the extension.

Backward Compatibility

If your extension fields are changing and you already have content files saved with your plugin, you will need to override these methods:

  • IExtension:onLoaded is called after all data has been set in the extension fields: all the inputs, outputs and parameters have been set to their correct value. This is where the additional data saved in onSaving can be restored. This is the final call to make sure internal data of the extension are ready.
  • IExtension::onRestoringConnection will be called when a connection cannot be restored because a field cannot be found. This method offer the chance to fix the connection by redirecting to the proper field. See Field connections below.

    Correct handling of versioning is mandatory for backward compatibility. For an example on backward compatibility, see code tutorial ExBackwardCompatibility located in folder <your Vortex Installation folder>/tutorials/ExBackwardCompatibility

Advanced backward compatibility: VxSim::IObsolete

When versioning becomes too complex, or the entire structure of an extension system has changed, it might be necessary to make an extension obsolete and to replace it with a new one. IObsolete has a single virtual method that will be called after a file has been totally loaded.

An example will show how to better use IObsolete. Here, we have an extension that has several fields.

Original extension
// original extension const int kCurrentVersion = 20150203; class MyExtension : public VxSim::IExtension { public: MyExtension(VxSim::VxExtension* proxy) : VxSim::IExtension(proxy, kCurrentVersion ) { } VxData::Field<double> inputPower; VxData::FieldArray<double> inputPowerCurve; };

inputPowerCurve has been moved to its own extension since it can be reused in a different context. Backward compatibility must be assured by loading the old extension and converting it to the new pair of extensions.

The obsolete extension needs be loadable with its data intact, just with the IObsolete interface added, keeping the same factory key.

Original extension, that is now obsolete
// obsolete extension const int kCurrentVersion = 20150203; class MyExtension : public VxSim::IExtension, public VxSim::IObsolete { public: MyExtension(VxSim::VxExtension* proxy) : VxSim::IExtension(proxy, kCurrentVersion ) { } virtual Vx::VxSmartPtr<VxExtension> replaceObsoleteExtension(VxSim::VxExtension* extension) override; VxData::Field<double> inputPower; VxData::FieldArray<double> inputPowerCurve; };

The new extensions also need to be created, and they should be registered in the extension factory.

New extension that will replace the obsolete extension
// new extensions class MyNewExtension : public VxSim::IObject { public: MyNewExtension (VxSim::VxObject* proxy) : VxSim::IObject(proxy) { } virtual bool isCompatible(VxExtension* extension) const override { VxSim::VxSmartInterface<MyNewSubExtension­> child = extension; return child.valid(); } VxData::Field<double> inputPower; }; class MyNewSubExtension : public VxSim::IExtension { public: MyNewSubExtension (VxSim::VxExtension* proxy) : VxSim::IExtension(proxy) { } VxData::FieldArray<double> inputPowerCurve; };

The method replaceObsoleteExtension() will be called when the file has been totally loaded. That's where the replacement is created and initialised.

Example of implementation for replaceObsoleteExtension()
Vx::VxSmartPtr<VxExtension> MyExtension::replaceObsoleteExtension(VxSim::VxExtension* extension) { // create the replacements auto newExtension = VxSim::VxSmartInterface<MyNewExtension>::create(); auto childExtension = VxSim::VxSmartInterface<MyNewSubExtension>::create(); newExtension.getObject()->add(childExtension.getExtension()); // transfer the data from the obsolete to the replacement newExtension->inputPower = this->inputPower; childExtension ->inputPowerCurve= this->inputPowerCurve; // return the replacement return newExtension.getObject(); }

The obsolete extension will be removed, and replaced with the new ones. Any references to the obsolete extension will be replaced with references to the new extensions.

Keyframes

The keyframes feature is based on fields. Keyframes are supported for all extensions. By default, all inputs, outputs and parameters are saved in a keyframe, and are restored with a keyframe. When the keyframe is restored, all observers will be called normally, so if your extension works correctly when a value is changed during simulation, no special code needs to be done to support keyframes.

If you need to do something specific, especially if your extension has private data that is not in the fields, you need to override these methods:

  • IExtension::onStateSave must not change anything in the extensions; its purpose is to inject the data needed to be able to correctly restore the state of the extension.
  • IExtension::onStateRestore should assume that all the inputs, outputs and parameters have been set to their correct value, and the simulation will continue shortly. This is the correct call to make sure internal data of the extension are ready.

Keyframes have no versioning. Keyframes saved with a previous version of an extension may not work as expected when the extension is modified.

See Vortex Studio SDK Advanced - Key Frames for more information.

Field Connections

Connecting extensions together is the best way to have data transfered from one object to another. Connections are created easily in the Editor. A connection's purpose is to transfer data from an output to an input.

  • A connection is from an output field to an input field.
  • Connection will be established if the output type can be converted to the input type.
    • Conversion may be with a loss of precision (e.g., double to int).
    • Pointer fields cannot be converted.
    • Enumeration can be connected to any int type and to string, and vice versa.
    • std::string and VxFilename can be connected to each other.
    • VxMath::Vector3 and VxMath::Matrix44 can be connected only if VxMath::Vector3 has its physical dimension set to kLength, kAngle, or kScale. The appropriate part of the VxMath::Matrix44 will be used.
  • VxData::Container can be connected if they are similar (have the same fields). If one value changes in the output, the corresponding input is changed, however if a field is added to the output container, it will NOT be added to the input container.
  • VxData::FieldArray<T> can be connected if they are of the same type. If the output is resized, the input will be resized as well.
  • Individual fields inside VxData::Container or inside VxData::FieldArray<T> can be connected.
  • Individual values inside VxData::Vector<T> cannot be connected.

Using the SDK, connections are contained in a VxContent::ConnectionContainerExtension.


A network simulator uses connections to know what data needs to be transferred between network nodes. Directly modifying an extension input will not work in a networked simulator; the modified value will stay local on the computer where it was changed. This may happen when an extension changes the inputs of another extension it accesses in a parameter Field.

Using VxSim::IMobile

When an extension implements IMobile, it adds to the extension the notion that it has a position, orientation and potentially a scale. IMobile adds three fields to the extension, they all represent a three-dimensional affine transformation matrix.

VxData::Field<VxMath::Matrix44> inputLocalTransform; VxData::Field<VxMath::Matrix44> inputParentTransform; VxData::Field<VxMath::Matrix44> outputWorldTransform;

inputParentTransform is the frame of reference for the extension.

inputLocalTransform is the offset from the frame of reference for the extension.

outputWorldTransform is the extension's absolute transformation matrix

NoteAlways use IMobile::outputWorldTransform if you are interested in the extension's real position, orientation and scaling.

The default calculation of outputWorldTransform is:

outputWorldTransform = inputParentTransform * inputLocalTransform;

This behavior is usually correct, but it can be overridden in your extension with _computeWorldTransform(). This calculation is triggered automatically whenever any of the two inputs of IMobile change.

VxSim::IMobile and VxSim::IObject

When IMobile is combined with IObject in an extension, the extensions outputWorldTransform will be transfered to the inputParentTransform of its IMobile children. The world transform will become the parent transform of its children, and thus their reference frame. Therefore, a hierarchy of IMobile becomes similar to a scene graph, where moving one IMobile will move all its children recursively.

The virtual method _onIMobileInputChanged() can be overriden to change that behavior.

Animating VxSim::IMobile

IMobile must be animated by connecting the source of the animation to inputParentTransform. The animated input becomes the frame of reference from which the world transform is calculated.

WarningConnecting inputLocalTransform is never guaranteed to work, as the frame of reference for the extension may be changed independently.

An animated VxDynamics::Part needs to be animated by modifying its inputParentTransform.

Network

Network is supported for all extensions. Only connected outputs are transferred by network. A large data packet is sent from master to all slaves, containing data for all connections. Smaller data packets are sent from a slave to all other nodes in the network when there is local value change on a connected output in a slave.

Only connected data fields are transmitted through network.

Large VxData::Vector<T> may take several simulation updates to be fully transferred. During simulation, changed values are sent immediately, but the entire vector is refreshed in a round-robin algorithm.

ILocalExtension

ILocalExtension is used to stop an extension from broadcasting values in the network. Usually, an extension is active in only one node in the network; there is only one dynamics module, the device is on one computer, the motion platform is in one place. However, some extension are updated simultaneously on several nodes in the network: there are usually several graphic slaves, HumanModule is needed for both graphics and dynamics, etc. Whenever an extension is updated on several nodes, the extension's outputs on each node will be updated. When they are connected, every node would send their value to all other nodes, flooding the network with data packets. ILocalExtension is designed to avoid this, by blocking outgoing network traffic from this extension.

NoteAll IGraphics are also ILocalExtension. It is safe to connect a IGraphic to another IGraphic: network won't interfere with the local calculations.

Record/Playback

Record/Playback is supported for all extensions. The values recorded for playback are the same as the values that were sent from the master to the slave. Recording does not need any special consideration when developing an extension. Playback must be handled correctly by modules; they need to handle VxSim::kModePlayingback in preUpdate(), update() and postUpdate(). Normally, a module will do nothing during playback; all inputs and outputs of extensions that were modified during recording will be played back automatically. However, an output device (like graphics, sound, possibly motion platform) should manage kModePlayingback just like kModeSimulating. The goal of playback is to show the same output as when it was simulating.