Why does Slap City keep forgetting my taunt button?

Posted by Programming Is Moe on Friday, May 14, 2021

TOC

Slap City

For you that don’t know, Slap City is a 2020 “movement fighter”/“smash clone” made with unity, featuring different characters from various games of its developer Ludosity. In my eyes Slap City is the best “smash clone” out there and actually has easily surpassed any of the original smash games. Slap City offers more gameplay depth for competitive players while still staying simple and fun when you don’t care about any of the more advanced techs. Some people may disregard Slap City for its graphics/style but for me the silly charm the game exudes is what makes me love it even more.

Although the game has one critical flaw: It doesn’t remember that you set your dpad to taunt.

Why care about taunts (More non technical fluff)

In Slap City, like most other fighting games, each character has a single taunt that they can execute by pressing the taunt button. In general taunts are just a joy to look at, and a great way to annoy your friends when you kill them or they refuse to approach.

Some of the taunts can even be held as long as you want to especially flex on your opponent (literally in the case of Fishbunjin 3000) or modify moves for stylishness points.

Beyond the cosmetic appeal of taunts, because the people behind Slap City are just that insane, many of the taunts have actual gameplay mechanics attached to them. For some characters you can press taunt after a grab to do a special finisher, heal your teammates or charge one of your attacks in style. They aren’t REALLY needed but, if you know about them, the altered angle of a “taunt” throw might mean the difference of getting a kill or not.

Finally there’s Business Casual Man, whose whole gimmick is his money counter, which can be spent on moves to augment their strength. And he really needs it because without it, his killing power is significantly reduced. He does have plenty “money making” attacks that net him cash on hit but guess what his taunt does:

Exactly, it loses him money. I’m just kidding, that only happens if you hold down the taunt. When you only tap it, Business Casual Man generates 50$ (which he loses if he gets hit while still in the taunt animation). But if a BCM manages to Star-KO someone he has plenty of time to do at least one taunt for FREE MONEY.

So for a BCM not having a working taunt is more detrimental than for any other character.

Where does the dpad taunt binding go?

Reproduction

  1. Start the game
  2. Set dpad from Movement to Taunt
  3. Restart and check –> dpad is set to Movement

Simple. Setting any other setting in the controls does get successfully persisted though… so what is happening?

Plausible Causes

For me the possible causes are the following:

  1. The game does not persist the dpad setting
  2. The game does not load the dpad setting correctly
  3. There is a faulty internal logic where the game sets the dpad to Movement on startup
  4. The developers intentionally ignore the setting just to fuck with people

Possibility 1: The game does not persist the dpad setting

First I gotta locate the settings file of the game which I will take a guess at and search for it inside the %userprofile% folder. And quicky I can find the %userprofile%/AppData/LocalLow/Ludosity/Slap City/controls.json containing the control settings. So what does it look like?

 {
    "pid": "pid0",
    "pn": "AMIRON",
    "c": {
        "XInput": {
            "f": [
                0,
                0,
                0,
                7,
                0,
                7,
                204,
                204,
                0,
                4,
                0,
                0,
                0
            ],
            "n": [
                0,
                0,
                0,
                0,
                0,
                0,
                0,
                [],
                0
            ],
            "b": {
                "start": "Pause",
                "A": "Special",
                "B": "Attack",
                "X": "Strong",
                "RT": "Shield",
                "LT": "Grab",
                "Y": "Jump",
                "RB": "Grab",
                "LB": "Dash"
            }
        }
    }
}

"b" is obviously the button config of my profile, "f" and "n" must be stick settings and advanced options like “What does shield do in the air”. So first things first, what happens if we set dpad to Taunt.

Nothing. Nothing changes. Curious. So it seems like the game does indeed not save it. But maybe it would load it, if set? Does the option even occur here ? With trial and error I tried to map out which value corresponds to what control in the options menu:

{
    "pid": "pid0",
    "pn": "AMIRON",
    "c": {
        "XInput": {
            "f": [
                0, <--Tap Jump
                0, <--Tap Strong Ground
                0, <--Tap Strong Air
                7, <--Tap Dash
                0,
                7,
                204,
                204,
                0, <--second Stick
                4, <--Grab in air
                0, <--Airdoge Treshhold mod
                0, <--Airdoge Treshhold mod
                0  <--Walljump Mode
            ],
            "n": [
                7, <--InputBuffer???
                0,
                0, <--temperature
                0, <--Deadzone
                0, <--Deadzone
                0, <--Fullpress
                0, <--Fullpress
                [], <-- axis disables
                0 
            ],
            "b": {
            ...
            }
        }
    }
}

HMMM.

I did find the corresponding value for each option toggle (except the dpad), but there are still 6 more left that can’t be matched. I could try to see what happens if I alter the options, especially the array index after Tab Dash seems suspicious but as I wanted to actually see why the game fails to save the dpad setting, I might as well look into the assembly now instead of guessing.

Why does the game not save the dpad setting?

Considering that this is a unity project it might be as simple as just decompiling the “main C# assembly” and looking through it. If the game interacts more with things set in the unity editor’s UI I might need to unpack the project with a unity asset explorer. Let’s throw the next best dll I can find into dotpeek and try to find the code that persists the controls…. Steam\steamapps\common\Slap City\Slap City_Data\Managed\Assembly-CSharp.dll into the disassembler you go.

Seeing as the json has, very short key names like "f" I gander that it’s not standard object serialization but something where the developer manually chose that name. So I search for the string "pid" inside the assembly, which brings me to the ControlSaveProfile

public class ControlSaveProfile
{
    public string profileId;
    public string profileName;
    public int profileNameDuplicate;
    public Dictionary<string, object> savedControls = new Dictionary<string, object>();
    private Dictionary<string, object> dict = new Dictionary<string, object>();

    public Dictionary<string, object> ToDict()
    {
        this.dict["pid"] = (object) this.profileId;
        this.dict["pn"] = (object) this.profileName;
        this.dict["c"] = (object) this.savedControls;
        return this.dict;
    }

    public void ParseObject(object obj)
    {
        if (!(obj is Dictionary<string, object> dictionary))
        return;
            object obj1;
        if (dictionary.TryGetValue("pid", out obj1))
            this.profileId = obj1 as string;
        if (dictionary.TryGetValue("pn", out obj1))
            this.profileName = obj1 as string;
        if (!dictionary.TryGetValue("c", out obj1))
        return;
        this.savedControls = obj1 as Dictionary<string, object>;
    }

    public void SetSavedControls(string controllerType, object controlConfig)
    {
        if (!this.savedControls.ContainsKey(controllerType))
            this.savedControls.Add(controllerType, controlConfig);
        else
            this.savedControls[controllerType] = controlConfig;
    }

    public bool TryGetSavedControls(string controllerType, out object controlConfig)
    {
        if (this.savedControls.ContainsKey(controllerType))
        {
            controlConfig = this.savedControls[controllerType];
            return true;
        }
        controlConfig = (object) null;
        return false;
    }
}

Definitely what is used in the saving process of the controls and whatever is utilizing it must handle the dpad control saving.

public class ControlSave
{
    private const string SettingsFormatMatchKey = "settingsformat_2";
    private static ControlSave _instance = new ControlSave();
    private Dictionary<string, object> savedControls = new Dictionary<string, object>();
    private Dictionary<string, object> recentProfiles = new Dictionary<string, object>();
    private List<object> savedProfiles = new List<object>();
    private List<ControlSaveProfile> loadedProfiles = new List<ControlSaveProfile>();

    [...]
    public bool TryGetProfileSavedControls(string controllerType, string profileId, out object controlConfig){}
    public void SetControllerTypeSaveToProfile(string controllerType, string profileId, object controlConfig)
    [...]

    public void WriteAndSave()
    {
        SaveFile.Instance.WriteObject("controls", "savedcontrols", (object) this.savedControls);
        this.savedProfiles.Clear();
        foreach (ControlSaveProfile loadedProfile in this.loadedProfiles)
        this.savedProfiles.Add((object) loadedProfile.ToDict());
        SaveFile.Instance.WriteObject("controls", "savedprofiles", (object) this.savedProfiles);
        SaveFile.Instance.WriteObject("controls", "recentprofiles", (object) this.recentProfiles);
        SaveFile.Instance.WriteObject("controls", "settingsformat_2", (object) "ok");
        SaveFile.Instance.CommitSave("controls");
    }
}

ControlSave is involved in writing the file but looking at the code, SaveFile.Instance.WriteObject seems unproblematic as it just writes whatever object it gets. As whatever is keeping the dpad setting from saving, only malfunctions for that single setting, it can’t be this as it never handles individual values. So the problem must occur in whatever fills savedprofiles. Which is derived from this.loadedProfiles.

As all properties are private the only thing that exposes the internal value is through the methods ControlSave.TryGetProfileSavedControls(..., out object controlConfig) for fetching, and can be set through ControlSave.SetControllerTypeSaveToProfile(string controllerType, string profileId, object controlConfig).

Let’s check out the usages of ControlSave.TryGetProfileSavedControls: 1 usage in SmashController.cs:53

public void LoadConfigFromProfile()
{
    if (ControlSave.Instance.HasLoadedProfile(this.UsingProfileId))
    {
        object controlConfig;
        if (ControlSave.Instance.TryGetProfileSavedControls(this.type.ToString(), this.UsingProfileId, out controlConfig))
        {
            this.ParseConfigDict(controlConfig as Dictionary<string, object>);
        }
        else
        {
            this.DefaultMisc();
            this.AssignDefaults();
        }
        }
        else
        {
        this.DefaultMisc();
        this.AssignDefaults();
    }
}

Which seems to be the class/file responsible for setting the options. Ok where is the dpad at?

There are only a few references to dpad

public class NonSyncCritical
{
    public bool digitalCtrl;
    public bool dpadClutch;
    public int temperature;
    public float deadZoneCtrl;
    public float deadZone2nd;
    public float fullPressCtrl;
    public float fullPress2nd;
    [...]

    public List<object> ToList()
    {
        List<object> objectList1 = new List<object>();
        objectList1.Add((object) (!this.digitalCtrl ? 0 : 7));
        objectList1.Add((object) (!this.dpadClutch ? 0 : 7));
        [...]
    }

    public void Parse(object obj)
    {
        if (!(obj is List<object>))
            return;
        List<object> objectList = obj as List<object>;
        int count = objectList.Count;
        if (count > 0)
            this.digitalCtrl = SmashParse.GetInt(objectList[0]) > 0;
        if (count > 1)
            this.dpadClutch = SmashParse.GetInt(objectList[1]) > 0;
        [...]
    }
}

This seems to be the actual internal class that is serialized into the json. KK let’s stare at that for a while and see if there is something wrong. But at least I now know that the dpad setting must the the second value of "n"

{
    "pid": "pid0",
    "pn": "AMIRON",
    "c": {
        "XInput": {
            "f": [
                ...
            ],
            "n": [
                7, <--digitalCtrl???
                0, <--Dpad ╰(*°▽°*)╯
                0, <--temperature
                0, <--Deadzone
                0, <--Deadzone
                0, <--Fullpress
                0, <--Fullpress
                [], <-- axis disables
                0 
            ],
            "b": {
            ...
            }
        }
    }
}

Waaaaaait a second. dpadClutch is a boolean. But in-game The options allow you to cycle through 3 settings: Movement, Clutch and Taunt. Am I looking at the right property? There really isn’t anything named dpad. No I double checked "f" aka SyncCritical and everything there is accounted for and 100% NOT related to dpad.

Better check out whatever is setting dpadClutch and maybe I can figure out if this is really related to the dpad taunt setting:

public class ControlsConfigMenu : MonoBehaviour
{
    [...]
    private void CycleDpad(int dir)
    {
        this.tickSFX.Play();
        int num1 = (!this.currentNonSync.digitalCtrl ? 0 : 1) + (!this.currentNonSync.dpadClutch ? 0 : 2);
        if (num1 == 3)
            num1 = 2;
        int num2 = num1 + dir;
        switch (num2)
        {
        case -1:
            num2 = 2;
            break;
        case 3:
            num2 = 0;
            break;
        }
        this.currentNonSync.digitalCtrl = num2 == 1;
        this.currentNonSync.dpadClutch = num2 == 2;
        this.SetMarkup();
    }
    [...]
    private void SetMarkup()
    {
        [...]
        this.commandMarkup[this.indexDpad].texts[1] = 
            !this.currentNonSync.digitalCtrl ? 
                (!this.currentNonSync.dpadClutch ? 
                    LocalizationSystem.GetText("ctrl_cfg_taunt") : 
                    LocalizationSystem.GetText("ctrl_cfg_clutch")
                ) : 
                LocalizationSystem.GetText("ctrl_cfg_movement");
        [...]
    }
    [...]
}

Oh I see, the NonSyncCritical.digitalCtrl and NonSyncCritical.dpadClutch boolean together control the dpad setting. When digitalCtrl == false && dpadClutch == false then it is set to Taunt. looking at the json in my files digitalCtrl is…. 7 which means according to the NonSyncCritical.Parse() line this.digitalCtrl = SmashParse.GetInt(objectList[0]) > 0 that digitalCtrl is true. And dpadClutch is 0 in the json, which means dpadClutch == false.

So right now, according to my settings I have: digitalCtrl == true && dpadClutch == false which is not correct if I want dpad to be Taunt.

I guess digitalCtrl is supposed to decide what kind of input the dpad is? Because it might not be “digital” but an analog stick? Or is it just there to override the taunt + clutch option with movement? Just from the naming and from the few usages I’ve looked at it makes no logical sense to me why digitalCtrl is even considered for the dpad is taunt logic or what that even exactly means.

Ok Let’s try this. If digitalCtrl == false && dpadClutch == false means taunt, then I will manually set XInput.n[0] = 0 and XInput.n[1] = 0 in the config file

"n": [
    0, <--digitalCtrl
    0, <--dpadClutch 
    ...
]

Advanced Reproduction 1

All Scenarios will always start with a controls.json set to a certain state.


Scenario 1

"n": [
    0, <--digitalCtrl
    0, <--dpadClutch 
    ...
]
  1. Start game
  2. Navigate into training mode using solely my analog stick
  3. Taunting with dpad is possible ✔
  4. Exit the game. Config is unchanged.

Scenario 2

"n": [
    0, <--digitalCtrl
    0, <--dpadClutch 
    ...
]
  1. Start game
  2. Navigate into training mode using solely my dpad
  3. Taunting with dpad is NOT possible ❌
  4. Exit the game. Config is unchanged.

Scenario 3

"n": [
    0, <--digitalCtrl
    0, <--dpadClutch 
    ...
]
  1. Start game
  2. Navigate into controls options using solely my Analog stick
  3. Dpad is set to Taunt
  4. Exit the game. Config is unchanged.

Scenario 4

"n": [
    0, <--digitalCtrl
    0, <--dpadClutch 
    ...
]
  1. Start game
  2. Navigate into controls options using solely my dpad
  3. I see that the controls.json was updated upon entering the controls options
"n": [
    7, <--digitalCtrl
    0, <--dpadClutch 
    ...
]
  1. Dpad is set to Movement
  2. Exit the game. Config is changed.

Conclusion: Something is setting the state of digitalCtrl to true when you navigate the menus. If I had to guess, the moment you enter the controller config menu that state gets saved to the controls.json.

Advanced Reproduction 2: Where can I navigate with the dpad WITHOUT loosing my dpad taunt.

All Scenarios will always start from a training match, with confirmed dpad taunt abilities.

Scenario 0

  1. Exit training match using dpad
  2. Go back to Select Mode
  3. Select Training using analogue stick
  4. Change character using analogue stick
  5. Select stage using analogue stick
  6. dpad Taunt works ✔

Scenario 1

  1. Exit training match using dpad
  2. Change character using dpad
  3. Select stage using analogue stick
  4. dpad Taunt works ✔

Scenario 2

  1. Exit training match using dpad
  2. Go back to Select Mode
  3. Select Training using dpad
  4. Change character using analogue stick
  5. Select stage using analogue stick
  6. dpad Taunt does not work ❌

Scenario 3

  1. Exit training match using dpad
  2. Go back to Select Mode
  3. Select Training using dpad
  4. Change character using analogue stick
  5. Open the control setting below character portrait
  6. Press Exit in the control settings
  7. Select stage using analogue stick
  8. dpad Taunt works ✔

Conclusions:

  • Something is setting the state of digitalCtrl to true when you navigate CERTAIN menus, like the mode select using the dpad.
  • Just “looking” at the settings makes the game sync the internal state to the settings state
  • The “altered” config never gets reflected to the controls.json

What is setting digitalCtrl to true when the dpad is used in menus and destroying my taunts :( ?

So basically, the problem revolves around digitalCtrl. Let’s find any write usage for digitalCtrl… Ah fuck. the “Show only read” and “Show only write” is greyed out. Good thing there are only 37 usages.

Ok the listed classes are:

  • CSControlConfig
  • CSPlayerBox
  • ConfigAnyControllerScreen
  • ControlsConfigMenu
  • GenericController
  • KeyboardController
  • OMMSelectController
  • OnlineControllerMenuManager
  • RL_Controllers
  • SmashController
  • SmashInput
  • WinGCController
  • WinGCNativeController
  • WindowsXbox360Controller

Filtering… manually anything that has only reads or I think I can ignore because it most likely not the cause. And we are left with everything “suspicious” and what is setting that darn property, which is actually a field (but I’m too lazy to go over the blog post and fix my wording :> )

  • CSControlConfig -> “Character Select Control Config?”
  • CSPlayerBox
  • ConfigAnyControllerScreen
  • OMMSelectController
  • OnlineControllerMenuManager
  • RL_Controllers
  • SmashController
  • WinGCController
  • WinGCNativeController
  • WindowsXbox360Controller
  • GenericController
  • KeyboardController

digitalCtrl WHAT ARE YOU MEANT TO REPRESENT?

  • WinGCController
  • WinGCNativeController
  • WindowsXbox360Controller
  • GenericController
  • KeyboardController

All of them are inheritors of SmashController and implement the different kind of controllers the game supports. Reading through them… I think I understand what that digitalCtrl is meant to represent.

Each of them set digitalCtrl in their constructor to false, except for the KeyboardController which sets it to true. I think it’s meant as a, “I’m solely digital and have no analog, I need the ““dpad”” to act as a movement” PROBABLY.

digitalCtrl Is overridden by…

  • RL_Controllers
  • OnlineControllerMenuManager
  • OMMSelectController
  • ConfigAnyControllerScreen
  • CSPlayerBox

They all seem to be menus. Menus where you can spawn a “target reticule” to navigate them. By default the target reticule does not get spawned till the game registers a controller input. And each of them have the same Controller reticule init logic somewhere inside: (except CSPlayerBox)

if (!this.HasSpawnedController(inputs[index].controller))
{
    instance.PollMenu(ref this.creationInput, inputs[index].controller);
    SmashInputFrame smashInputFrame = this.creationInput.GetBuffer()[0];
    if ((double) smashInputFrame.state.controlDir0.sqrMagnitude > 0.5)
    {
        OCMController ocmController = UnityEngine.Object.Instantiate<OCMController>(this.controllerPrefab, this.spawnHere.position, Quaternion.identity, this.transform);
        if (smashInputFrame.state.menuDpad)
        {
            inputs[index].controller.LoadSavedConfig();
            inputs[index].controller.nonsync.digitalCtrl = true;
            inputs[index].controller.nonsync.dpadClutch = false;
            inputs[index].controller.SaveCurrentConfig();
        }
        ocmController.SetController(inputs[index].controller);
        ocmController.onSelectController = new Action<SmashController>(this.SelectControllerOne);
        ocmController.onSelectControllerTwo = new Action<SmashController>(this.SelectControllerTwo);
        ocmController.onQuit = new Action(this.q);
        this.activeControllers.Add(ocmController);
    }
}

smashInputFrame.state.menuDpad gets set by all the controller implementations anytime you use the dpad of the controllers (in the menu). And in the case of the Keyboard it actually get set without any condition.

In the case of CSPlayerBox (Character Select?) that sets the digitalCtrl. Maybe that gets triggered when you select who you want to be ? As in, Player 1 ?

public void SetController(SmashController ctrl, bool dpadpls = false)
{
    bool flag = ctrl != this.mController;
    this.mController = ctrl;
    if (this.mController != null)
        this.mController.LoadSavedConfig();
    if (!flag)
        return;
    this.playerActive = true;
    this.selected = false;
    this.chiphandler.RequestMyChip(this.player);
    this.pointer.ptrTransform.gameObject.SetActive(true);
    this.boxMenu.currentMenu = !this.cpu ? 1 : 4;
    this.nametaginput.ctrltype = this.mController.type;
    this.ccinput.ctrltype = this.mController.type;
    this.ccinput.ctrlnametext.text = !string.IsNullOrEmpty(this.mController.UsingProfileId) ? this.mController.FancyName() + "\n" + ControlSave.Instance.GetProfileName(this.mController.UsingProfileId) : this.mController.FancyName();
    if (!dpadpls)
        return;
    this.mController.nonsync.digitalCtrl = true;
    this.mController.nonsync.dpadClutch = false;
}

Yes indeed. If you go into the character select and your controller has no assigned “player slot”, if the game detects that you were using a dpad then it will run into the last 2 instructions and kill the taunt and assign you to the next free “player slot”, because it assumes that “You want to move with the dpad”.

Conclusion: It’s a feature not a bug

Ludosity is at fault for trying to be helpful.

If the game sees that the first thing you do with a controller in a menu, is to move it with the dpad, then it assumes you will also want to move your character in the game with it. And the game will automatically rebind dpad to Movement. As far as I can tell this special behavior is not a “hacky fix” for the keyboard or controllers as all of them would work fine no matter what digitalCtrl is set to. Both the keyboard and controllers just virtually set the desired menu movement direction when you press your WASD/Dpad without the state of digitalCtrl being involved. It’s just for user convenience, to enable in-game movement with the dpad. Which goes against the user’s settings though.

Judging from the defaults, they intended the dpad to be Taunt, it’s how the controllers get initialized and what SmashController.DefaultMisc()/SmashController.SimpleMisc() sets it to be. And that’s also how SSBM worked, dpad is taunt, and dpad can be used to navigate the menu but not be used as in-game movement.

Me and my friend just assumed that it was a bug because SOMETIMES our taunts worked after starting the game and sometimes they didn’t. Seems like on the days it worked we randomly decided to use the analogue stick for menu navigation. The behavior seemed so random to me that I actually believed that Ludosity had a function in the game that randomly reset the dpad to movement in order to punish rude players :V

In my opinion as a web dev: Never take control away from the user or change user settings without the user’s knowledge, as it only serves to confuse them when things happen contrary to the settings they consciously put in. It’s probably there for new players in order to make the first time experience as hassle free as possible but… uhg, every second day I have to fix the setting. The devs are aware of that “feature” and consider it to be more user friendly. At least I had a bit of fun looking through the code of the game.

Still best smash tho, Ludosity pls fix.