Jump to content

22 posts in this topic Last Reply

Highlighted Posts

Posted:
Last Online:  
 

In this tutorial, we will replicate the functionality of the "Make Historical" checkbox in SimCity 4 (stop building from leveling up). The mod will utilize the same interfaces as the Control Building Level Up mod. You will learn how to inspect and modify user interfaces with ModTools, how the info panels of the game work, how to add a checkbox to the UI, how to control building level up with the official API, how to get notified when a building is added/removed and how to serialize custom data in the savegame. Once again, we will use the loading and threading hooks provided by the API.

SimCity4-make_historical.jpg

 

Step 1: Project Setup

Create a new project named "MakeHistorical" in VS2017, following Method 2 in Tutorial 0 (working with a text editor is still possible in this tutorial, but not recommended)

Your IUserMod implementation should look like this:

using ICities;

namespace MakeHistorical
{
    public class MakeHistoricalMod : IUserMod
    {
        public string Name => "Make Historical";
        public string Description => "Prevents the level up of buildings";
    }
}

 

Step 2: Data Storage with Serialization Hook

Data Structure

We will store the ids (ushort) of the historical building instances in the savegame. Like in Tutorial 2 Step 4, we will create a data class that holds our data:

using System.Collections.Generic;

namespace MakeHistorical
{
    public class MakeHistoricalData
    {
        public List<ushort> HistoricalBuildingIds { get; set; } = new List<ushort>();
    }
}

The game requires additional savegame data to be in byte[] (byte array) format (basically zeros and ones, very low level). Luckily, Colossal Order provides a few useful tools to convert data classes to byte arrays (package ColossalFramework.IO). To use these tools, our data class must implement the IDataContainer interface.

Extend the class like this:

using System.Collections.Generic;
using System.Linq;
using ColossalFramework.IO;

namespace MakeHistorical
{
    public class MakeHistoricalData : IDataContainer
    {
        // The key for our data in the savegame
        public const string DataId = "MakeHistorical";

        // Version of data save format
        // This is important when you add new fields to MakeHistoricalData
        public const int DataVersion = 0;


        public List<ushort> HistoricalBuildingIds { get; set; } = new List<ushort>();


        // This serializes the object (to bytes)
        public void Serialize(DataSerializer s)
        {
            // convert ushort list to int array
            int[] ids = HistoricalBuildingIds.Select(id => (int)id).ToArray();

            s.WriteInt32Array(ids);
        }

        // This reads the object (from bytes)
        public void Deserialize(DataSerializer s)
        {
            int[] ids = s.ReadInt32Array();

            // convert int array to ushort list
            HistoricalBuildingIds = ids.Select(id => (ushort)id).ToList();
        }

        // Validates that all building ids are active
        public void AfterDeserialize(DataSerializer s)
        {
            if (!BuildingManager.exists) return;

            List<ushort> validatedBuildingIds = new List<ushort>();

            Building[] buildingInstances = BuildingManager.instance.m_buildings.m_buffer;

            // itertate through all building ids, filter active ids
            foreach (ushort buildingId in HistoricalBuildingIds)
            {
                if (buildingInstances[buildingId].m_flags != Building.Flags.None)
                {
                    validatedBuildingIds.Add(buildingId);
                }
            }

            HistoricalBuildingIds = validatedBuildingIds;
        }
    }
}

What happened?

  • We added the constants DataId and DataVersion. We will use these in the next step
  • We implemented the Serialize method: In this method, we convert our list of ushort numbers to an integer array (the serializer does not support ushort) and pass it to a DataSerializer, which converts our array to bytes.
  • We implemented the Deserialize method, which does the opposite of Serialize
  • We implemented AfterDeserialize, which validates that all deserialized building ids belong to buildings (important when the mod was disabled for some time, to keep the data clean)

Serialization System

Colossal Order provides a serialization hook for mods (ISerializableDataExtension/SerializableDataExtensionBase). This hook is invoked when the savegame data is loaded, and when the user presses the save button. The game also provides a storage interface (ISerializableData) that allows us to write byte arrays to savegame and read byte arrays from savegame.

Combined with the data class and the byte array conversion tools provided by Colossal Order, we will use this hook to save our data. Add a new MakeHistoricalDataManager class:

using ColossalFramework.IO;
using ICities;
using System.IO;
using UnityEngine;

namespace MakeHistorical
{
    public class MakeHistoricalDataManager : SerializableDataExtensionBase
    {
        // The data object of our mod
        private MakeHistoricalData _data;

        public override void OnLoadData()
        {
           // Get bytes from savegame
            byte[] bytes = serializableDataManager.LoadData(MakeHistoricalData.DataId);
            if (bytes != null)
            {
                // Convert the bytes to MakeHistoricalData object
                using (var stream = new MemoryStream(bytes))
                {
                    _data = DataSerializer.Deserialize<MakeHistoricalData>(stream, DataSerializer.Mode.Memory);
                }

                Debug.LogFormat("Data loaded (Size in bytes: {0})", bytes.Length);
            }
            else
            {
                _data = new MakeHistoricalData();

                Debug.Log("Data created");
            }
        }

        public override void OnSaveData()
        {
            byte[] bytes;

            // Convert the MakeHistoricalData object to bytes
            using (var stream = new MemoryStream())
            {
                DataSerializer.Serialize(stream, DataSerializer.Mode.Memory, MakeHistoricalData.DataVersion, _data);
                bytes = stream.ToArray();
            }

            // Save bytes in savegame
            serializableDataManager.SaveData(MakeHistoricalData.DataId, bytes);

            Debug.LogFormat("Data saved (Size in bytes: {0})", bytes.Length);
        }
    }
}

What happens here?

  • The _data property stores our mod data
  • The OnLoadData method attempts to read the saved byte array from the savegame. If the array was found, it converts the bytes to a MakeHistoricalData object (our data structure). If no array was found, it creates a new MakeHistoricalData object (new city or mod enabled for the first time).
  • The OnSaveData method converts the MakeHistoricalData object to bytes, then saves the bytes in the savegame.

Tip: To understand how data versioning works, look at this example data manager.

Singleton

To make the data readable from other classes, we will make MakeHistoricalDataManager implement a simple singleton pattern. We willl also add a few methods to lookup, add and remove building ids:

// Singleton getter
public static MakeHistoricalDataManager Instance { get; private set; }


public bool IsHistorical(ushort buildingId)
{
    return _data.HistoricalBuildingIds.Contains(buildingId);
}

public void AddBuildingId(ushort buildingId)
{
    if (_data.HistoricalBuildingIds.Contains(buildingId)) return;

    _data.HistoricalBuildingIds.Add(buildingId);

    Debug.Log($"Historical Building {buildingId} added");
}

public void RemoveBuildingId(ushort buildingId)
{
    if (!_data.HistoricalBuildingIds.Contains(buildingId)) return;

    _data.HistoricalBuildingIds.Remove(buildingId);

    Debug.Log($"Historical Building {buildingId} removed");
}


public override void OnCreated(ISerializableData serializedData)
{
    base.OnCreated(serializedData);

    Instance = this; // initialize singleton
}

public override void OnReleased()
{
    Instance = null; // reset singleton
}

Now we can use a simple statement to check if a building is historical:

bool h = MakeHistoricalDataManager.Instance.IsHistorical(1234);

Final code of the MakeHistoricalDataManager class.

 

Step 3: Removing destroyed buildings from the list

We have to remove buildings from the list when they are buldozed. Luckily CO added a hook just for that (not documented in the wiki):

using ICities;
using UnityEngine;

namespace MakeHistorical
{
    public class MakeHistoricalBuildingMonitor : BuildingExtensionBase
    {
        public override void OnBuildingReleased(ushort id)
        {
            if (MakeHistoricalDataManager.Instance.IsHistorical(id))
            {
                // Remove demolished/destroyed buildings from the list of historical buildings
                MakeHistoricalDataManager.Instance.RemoveBuildingId(id);
            }
        }
    }
}

Tip: With the IBuildingExtension/BuildingExtensionBase hook, you can also manipulate which buildings are spawned, and there are hooks for building creation and relocation.

 

Step 4: Inspecting And Manipulating User Interfaces with ModTools and ILSpy

Like in SimCity 4, we will add a "Make Historical" checkbox to the building info window:

3Bge1br.png

To do that, we have to find out how to access the info window programmatically.

ModTools supports us with its "debug view". You can toggle it with CTRL + R. Then move your mouse over the info panel (so that the tint of the panel changes to green) and press CTRL + F:

tAWyV2T.pngZHu9isG.png

The UI window component is now displayed in the ModTools scene explorer. The class name of the element is ZonedBuildingWorldInfoPanel.

In the scene explorer, we can change properties like the colour, size and position of the window. We can also look at the child UI elements (buttons, text labels). The position of the child elements is determined by the relativeLayout property.

Noted values:

  • Height: 321
  • Left Padding: 14 (based on the child element positions)
  • Bottom Padding: 27 (based on the child element positions)

Now that we know the class name, we can locate the class in ILSpy to find out how to access it programmatically:

TgolgwO.png

ZonedBuildingWorldInfoPanel is a subclass of WorldInfoPanel, which contains a static Show<>() method:

// WorldInfoPanel (taken from ILSpy)
public static void Show<TPanel>(Vector3 worldMousePosition, InstanceID instanceID) where TPanel : WorldInfoPanel
{
	TPanel tPanel = UIView.library.Show<TPanel>(typeof(TPanel).Name, false);
	tPanel.SetTarget(worldMousePosition, instanceID);
	tPanel.component.opacity = 1f;
}

After further investigation (use ILSpy's "Analyze" feature), you will find a Get<>() method in UIView.library which returns the ZonedBuildingWorldInfoPanel instance:

var panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);

Note: There is only one panel instance, which is just filled with the data of the currently selected building. The panel even exists when no building is selected, it's just hidden.

 

Step 5: Adding a Checkbox to the Info Panel

Finding the right property values for user interfaces is a finicky task, especially when you have to restart the game after every little change. Using the ModTools console, we can add and modify our checkbox while the game is running. Run this script in the ModTools console (F7):

var panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);
var checkBox = panel.component.AddUIComponent<UICheckBox>();

checkBox.width = panel.component.width;
checkBox.height = 20f;
checkBox.clipChildren = true;

UISprite sprite = checkBox.AddUIComponent<UISprite>();
sprite.spriteName = "ToggleBase";
sprite.size = new Vector2(16f, 16f);
sprite.relativePosition = Vector3.zero;

checkBox.checkedBoxObject = sprite.AddUIComponent<UISprite>();
((UISprite)checkBox.checkedBoxObject).spriteName = "ToggleBaseFocused";
checkBox.checkedBoxObject.size = new Vector2(16f, 16f);
checkBox.checkedBoxObject.relativePosition = Vector3.zero;

checkBox.label = checkBox.AddUIComponent<UILabel>();
checkBox.label.text = " ";
checkBox.label.textScale = 0.9f;
checkBox.label.relativePosition = new Vector3(22f, 2f);

checkBox.name = "MakeHistorical";
checkBox.text = "Make Historical";

This will add everything that is required for a checkbox, giving it the name "MakeHistorical". It is still in the wrong position:

je4064q.png

Tip: SamsamTS created a very useful utility class that allows you to create various UI elements. The code above was copied from that class.

 

With ModTools, we can experiment with the position without restarting the game. Execute this code in the ModTools console:

var panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);
var checkBox = panel.component.Find<UICheckBox>("MakeHistorical");

checkBox.relativePosition = new Vector3(14f, 164f + 130f + 5f);

Result:

jtev90q.png

 

Now we just have to increase the height of the window a little bit. Execute this code in the ModTools console:

var panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);
panel.component.height = 321f + 5f + 20f;

Result:

SPeIut9.png

 

Now, we will put the code we just executed with the console into the loading hook of our mod:

using ColossalFramework.UI;
using ICities;
using UnityEngine;

namespace MakeHistorical
{
    public class MakeHistoricalLoading : LoadingExtensionBase
    {
        private UICheckBox _makeHistoricalCheckBox;

        public override void OnLevelLoaded(LoadMode mode)
        {
            if (_makeHistoricalCheckBox != null) return;

            ZonedBuildingWorldInfoPanel panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);
            UICheckBox checkBox = panel.component.AddUIComponent<UICheckBox>();

            checkBox.width = panel.component.width;
            checkBox.height = 20f;
            checkBox.clipChildren = true;

            UISprite sprite = checkBox.AddUIComponent<UISprite>();
            sprite.spriteName = "ToggleBase";
            sprite.size = new Vector2(16f, 16f);
            sprite.relativePosition = Vector3.zero;

            checkBox.checkedBoxObject = sprite.AddUIComponent<UISprite>();
            ((UISprite)checkBox.checkedBoxObject).spriteName = "ToggleBaseFocused";
            checkBox.checkedBoxObject.size = new Vector2(16f, 16f);
            checkBox.checkedBoxObject.relativePosition = Vector3.zero;

            checkBox.label = checkBox.AddUIComponent<UILabel>();
            checkBox.label.text = " ";
            checkBox.label.textScale = 0.9f;
            checkBox.label.relativePosition = new Vector3(22f, 2f);

            checkBox.name = "MakeHistorical";
            checkBox.text = "Make Historical";

            checkBox.relativePosition = new Vector3(14f, 164f + 130f + 5f);

            panel.component.height = 321f + 5f + 16f;

            _makeHistoricalCheckBox = checkBox;
        }
    }
}

fMWkAsw.png

Compile the mod (F6). If there are any compilation errors, use the error list in VS2017 to locate and fix the error (View > Error List)

When the compilation was successful, run the game. Enable the mod in content manager (if your mod does not show up, you probably forgot to setup the post build script).

Now create or load a city, open the building info window for a growable building. Et voila:

afzSqvn.png

 

Step 6: Processing Checkbox Events

To process checkbox events, we will add a delegate to the CheckedChange event:

public override void OnLevelLoaded(LoadMode mode)
{
    ...

    checkBox.eventCheckChanged += (component, check) =>
    {
        ushort buildingId = WorldInfoPanel.GetCurrentInstanceID().Building;

        if (check)
        {
            MakeHistoricalDataManager.Instance.AddBuildingId(buildingId);
        }
        else
        {
            MakeHistoricalDataManager.Instance.RemoveBuildingId(buildingId);
        }
    };
}

Clicking the checkbox now triggers the data manager:

ZJWDvMP.png

 

Step 7: Monitoring the ZonedBuildingWorldInfoPanel with a Threading Hook

There is another problem: The checkbox is not updated when you select a building. We will use a threading hook to update the checkbox:

using ColossalFramework.UI;
using ICities;

namespace MakeHistorical
{
    public class MakeHistoricalPanelMonitor : ThreadingExtensionBase
    {
        private ZonedBuildingWorldInfoPanel _panel;
        private UICheckBox _makeHistoricalCheckBox;

        private ushort _lastBuildingId = 0;

        // called every frame
        public override void OnUpdate(float realTimeDelta, float simulationTimeDelta)
        {
            if (!FindComponents()) return;

            if (_panel.component.isVisible)
            {
                ushort buildingId = WorldInfoPanel.GetCurrentInstanceID().Building;

                if (_lastBuildingId != buildingId)
                {
                    // display the right checkbox state  
                    _makeHistoricalCheckBox.isChecked = MakeHistoricalDataManager.Instance.IsHistorical(buildingId);
                    _lastBuildingId = buildingId;
                }
            }
            else
            {
                _lastBuildingId = 0;
            }
        }

        private bool FindComponents()
        {
            if (_panel != null && _makeHistoricalCheckBox != null) return true;

            _panel = UIView.library.Get<ZonedBuildingWorldInfoPanel>(typeof(ZonedBuildingWorldInfoPanel).Name);
            if (_panel == null) return false;

            _makeHistoricalCheckBox = _panel.component.Find<UICheckBox>("MakeHistorical");
            return _makeHistoricalCheckBox != null;
        }
    }
}

 

Step 8: Controlling Building Level Up

It took quite some time to get to this point (UI and serialization are always very time consuming). The last missing piece is the ILevelUpExtension/LevelUpExtensionBase hook, which monitors and manipulates the level up behaviour of buildings. Probably the least complicated part of the mod:

using ICities;

namespace MakeHistorical
{
    public class MakeHistoricalLevelUpMonitor : LevelUpExtensionBase
    {
        public override ResidentialLevelUp OnCalculateResidentialLevelUp(ResidentialLevelUp levelUp, int averageEducation, int landValue,
            ushort buildingID, Service service, SubService subService, Level currentLevel)
        {
            if (MakeHistoricalDataManager.Instance.IsHistorical(buildingID))
            {
                levelUp.targetLevel = currentLevel;
            }

            return levelUp;
        }

        public override CommercialLevelUp OnCalculateCommercialLevelUp(CommercialLevelUp levelUp, int averageWealth, int landValue,
            ushort buildingID, Service service, SubService subService, Level currentLevel)
        {
            if (MakeHistoricalDataManager.Instance.IsHistorical(buildingID))
            {
                levelUp.targetLevel = currentLevel;
            }

            return levelUp;
        }

        public override IndustrialLevelUp OnCalculateIndustrialLevelUp(IndustrialLevelUp levelUp, int averageEducation, int serviceScore,
            ushort buildingID, Service service, SubService subService, Level currentLevel)
        {
            if (MakeHistoricalDataManager.Instance.IsHistorical(buildingID))
            {
                levelUp.targetLevel = currentLevel;
            }

            return levelUp;
        }

        public override OfficeLevelUp OnCalculateOfficeLevelUp(OfficeLevelUp levelUp, int averageEducation, int serviceScore, ushort buildingID,
            Service service, SubService subService, Level currentLevel)
        {
            if (MakeHistoricalDataManager.Instance.IsHistorical(buildingID))
            {
                levelUp.targetLevel = currentLevel;
            }

            return levelUp;
        }
    }
}

The logic is very simple: When the building is historical, the target level is set to the current building level.

And we are done! Buildings with the "Make Historical" checkbox enabled will no longer level up. The list of historical buildings is stored in the savegame.

WgpCccu.png

Happy Coding!

Download Source

  • Like 7

Share this post


Link to post
Share on other sites
Posted:
Last Online:  
 

OMG, so many useful things here :).

Are you a teacher in RL?

 

Share this post


Link to post
Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    7 minutes ago, noob_vl said:

    OMG, so many useful things here :).

    Are you a teacher in RL?

    No, I'm studying mechanical engineering.

    So far we only scratched the surface of what is possible with mods ;)

    • Like 1

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     
    11 minutes ago, boformer said:

    ...  So far we only scratched the surface of what is possible with mods ;)

    So next tut on detours :) .... Fine Road Anarchy seem like a good example to learn from. At least for me it was.

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

    I wonder if it's possible to make a mod that defines Districts by street boundaries (as opposed to the brush tool)? Could be an "easy" mod to make in tutorial form -- I say "could", since it's a simple idea but execution of it could be way more difficult haha.

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    1 hour ago, alborzka said:

    I wonder if it's possible to make a mod that defines Districts by street boundaries (as opposed to the brush tool)? Could be an "easy" mod to make in tutorial form -- I say "could", since it's a simple idea but execution of it could be way more difficult haha.

    Everything that involves drawing shapes and paths is very difficult, as it involves a lot of math (terraforming, district drawing, zoning, placing networks).

    It would be too much for a tutorial.

    • Like 1

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

    Really helpful tutorials! Is a tutorial about loading custom textures or files and replacing game's files coming anytime soon?

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    7 hours ago, pauliaxz said:

    Really helpful tutorials! Is a tutorial about loading custom textures or files and replacing game's files coming anytime soon?

    Maybe in 1-2 months. The next tutorials will be about detours, and I want to make another one about UI development.

    Loading custom textures is not that hard, maybe this will help you.

    • Like 1

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

    I looked at that earlier, but texture location there is "Steam\steamapps\common\Cities_Skylines\tt", the thing is I can't get it to load from "AppData\Local\Colossal Order\Cities_Skylines\Addons\Mods\mymodname" or from the workshop folder location

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    2 hours ago, pauliaxz said:

    I looked at that earlier, but texture location there is "Steam\steamapps\common\Cities_Skylines\tt", the thing is I can't get it to load from "AppData\Local\Colossal Order\Cities_Skylines\Addons\Mods\mymodname" or from the workshop folder location

    You can get the path of your mod like this:

    public static string GetModPath()
    {
        foreach (var current in PluginManager.instance.GetPluginsInfo())
        {
            if (current.publishedFileID.AsUInt64 == 543722850UL || current.name.Contains("NetworkSkins")) 
            {
                return current.modPath;
            }
        }
        return "";
    }

    (543722850 is the ID of the workshop item)

    The method above works for local mods (AppData) and workshop mods (SteamApps/workshop).

    Just add the method to your IUserMod implementation, and use it like this:

    string texturePath = Path.Combine(NetworkSkinsMod.GetModPath(), "texture_d.png");

    (Path.Combine for Linux/Mac support)

    • Like 1

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

    So basically the:

     current.name.Contains("mod folder name in AppData"))

    gets the local path?

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    1 hour ago, pauliaxz said:

    So basically the:

    
     current.name.Contains("mod folder name in AppData"))

    gets the local path?

    No. PluginManager.instance.GetPluginsInfo() returns a list of mod metadata objects (PluginInfo), one for every mod you see in content manager. The metadata objects contain the mod name, the workshop id and the mod path.

    The for loop in my code iterates through all metadata objects to find the one that belongs to Network Skins ("NetworkSkins" is the name of the folder/dll file), then returns the mod path stored in the metadata object.

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

    Is there any way you can publish the source code of your LOD Toggler mod? I want to combine its functionality with the checkbox functionality taught here by making a button to toggle LOD on/off for checked buildings only.

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    4 hours ago, alborzka said:

    Is there any way you can publish the source code of your LOD Toggler mod? I want to combine its functionality with the checkbox functionality taught here by making a button to toggle LOD on/off for checked buildings only.

    You can use ILSpy to decompile it. Just to give you a hint, what you want to do is not easily possible.

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     
    5 hours ago, boformer said:

    You can use ILSpy to decompile it. Just to give you a hint, what you want to do is not easily possible.

    Hmm, do you think it could be possible based on what I learn from upcoming tutorials maybe? Or do you mean it's something even advanced modders would have trouble with?

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    7 hours ago, alborzka said:

    Hmm, do you think it could be possible based on what I learn from upcoming tutorials maybe? Or do you mean it's something even advanced modders would have trouble with?

    Both.

    Share this post


    Link to post
    Share on other sites
  • Original Poster
  • Posted:
    Last Online:  
     
    34 minutes ago, mntoes said:

    ^^^ That link does not seem to work?

    Now it works again.

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     
    On 8/25/2017 at 3:47 AM, boformer said:

    Now it works again.

    Not anymore


    Known as LeonardMT everywhere else

    Share this post


    Link to post
    Share on other sites
    Posted:
    Last Online:  
     

     @boformer still you around? I found your CS mods tutorials now only... and they are amazing.

    are you avaliable for a paid mod create or paid code assistance?

    thank you.

     

    Share this post


    Link to post
    Share on other sites

    Sign In or register to comment...

    To comment in reply, you must be a community member

    Sign In  

    Already have an account? Sign in here.

    Sign In Now

    Create an Account  

    Sign up to join our friendly community. It's easy!  

    Register a New Account


    ×

    Thank You for the Continued Support!

    Simtropolis depends on donations to fund site maintenance costs.
    Without your support, we just would not be in our 24th year online!  You really help make this a great community. *:thumb:

    But we still need your support to stay online. If you're able to, please consider a donation to help us stay up and running. This helps sustain a platform where we can share our community creations for years to come.

    Make a Donation, Get a Gift!

    Expand your city with the best from the Simtropolis Exchange.
    Make a Donation and get one or all three discs today!

    STEX Collections

    By way of a "Thank You" gift, we'd like to send you our STEX Collector's DVD. It's some of the best buildings, lots, maps and mods collected for you over the years. Check out the STEX Collections for more info.

    Each donation helps keep Simtropolis online, open and free!

    Thank you for reading and enjoy the site!

    More About STEX Collections