The Long and the Devil

WARNING: This post contains the kind of technical detail you either like or don’t… but a lot of folk tell us they find these inside glimpses interesting.

“I am not sure whether the game is still being supported,” said a recent help ticket about Cultist Simulator. It is, and in fact we keep Chelnoque (who also helped with BOOK OF HOURS code) on retainer specifically to fix issues with CS. After six years it’s pretty stable… but that sometimes means that where bugs still exist, they’re weirdFortunately Chel is a dogged pursuer. Here’s his recent after-action report on one particularly stubborn blemish, as a guest post.


You may remember a CS issue that still gets reported occasionally, with followers losing follower aspect in Apostles after being injured by an enemy Long – I was finally able to track it down (almost two years since the first report). The root cause for this one is localization (of course it is), so might be somewhat relevant for BoH.

tl;dr version (spoils some revelations): if you have more than 1 mutation in an internal recipe, any loc will break it unless you apply my fix||

Actual repro steps turned out to be simple – on any language other than English, the recipe long.executestrategy.injurefollower.defencefailed doesn’t mutate follower_wound correctly. (wanted to brag that by now I am able to write this recipe’s id by memory but apparenty I still can’t)

Normally, it does two mutations – follower_wound: 1follower: -1. But with loc, follower_wound mutation id gets replaced with follower in this specific recipe. Why?

Mutations property is an array. UniqueIdBuilder, when making unique ids  (which, in turn, are responsible for making associations between core and loc content) doesn’t care whether something is in array, and gives sub-ids like mutations.filter – as if “filter” was mutation’s own property, but, of course, it’s a sub-property of a mutation object that mutations array contains. So both mutations’ filters in this recipe had an id like (simplified) >long.executestrategy.injurefollower.defencefailed.mutations.filter, making them essentially the same thing as far as locs are concerned.

But, “Chelnoque”, you will say (more eloquently probs), “Chelnoque, I distinctively remember having more than one mutation in other recipes too. Why don’t they bug out? And why only in localizations? And mutations isn’t a localizable property, how come loc even affects it?”

“Why only in localizations” is fairly easy to answer. Unique id plays no role in core content load as the loaded data there is stored in a list, while loc stores all data in a dict (so it can be later found and applied to loaded core data) by unique id. Thus, same unique ids overwrite each other.

But why only this specific recipe? Well, because it’s internal. The only internal recipe in the game that uses mutations.
Why it is important that it’s internal?
In short, “because localizable sub-entities are localized without a regard to their own localizable properties”.

For example, recipes have slots. Slots have localizable properties – label, description. But recipes don’t care about that. If there’s a slot defined in a loc, all of its properties will be seen as eligible for localization. Why? Giving the floor back to AK, in comments:

//Note: this doesn’t work quite in the way I intended it. Label and Description on Slots are marked as Localise, but
//the attribute is inspected only for the top level entity. Because the top level entity also has Label and Description marked as localise,
//the slot properties are added to the localisable keys, but this will break if the names are different. Consider explicitly inspecting subproperty attributes
//to see if they’re also subentities, when loading the data
//note: “explicitly inspecting subproperty attributes” won’t work in cases like “a normal Recipe defined inside “linked”
//LinkedRecipeDetails has no explicit properties like Label/Description, so they can’t be marked as localizable, so they won’t be included here


//There’s another bug here that approximately cancels out the bug described above when scanning for localisable keys.
//We only check the top-level property, not its sub-properties. So if we’re registering loc data for ‘linked’ (localisable=true)
//we also register loc data for all its sub properties – eg both ‘startdescription’ (hurray) and ID (minor perf pain)

Okay, to sum it up.

  • Mutations aren’t localized normally
  • But links are localized, because they may contain internal recipes that may contain text data
  • Being sub-entities, they are localized in their entirety, loader not knowing what properties in them are localizable or not localizable
  • And knowing would do shit all, in fact, because we’re actually defining an entirely different entity here – a recipe, not a link
  • So we have no other way but to localize everything which means we accidentally localize non-text gameplay data which means it needs to be synced across locs
  • And also means the array stuff I started with becomes relevant because it means mutations become mixed in translation of internal recipes
  • But actually NOT EXACTLY, because there are set of clever traps and checks to prevent redundant localization – for example, only string properties’ locs are applied (meaning that, say, slot reqs won’t be localized; and "greedy": true won’t be applied; but "greedy": "true" will be)

BUT !!! filter and mutate properties are strings, which means they are eligible for localization again (and, consecutively, for a mix-up)

Phew. As you can see, the Devil had to work really hard to make this one happen. Loc pipeline is a minefield (not for us – for bugs) and it got passed with a commendable grace.

Well now. The most immediate way to fix that is to just to make id builder to index array entries (which I did), but that still doesn’t address all other stuff that surfaced during this investigation. So

1) I took the liberty of slightly rewriting the loc pipeline so it actually knows and respects sub-entities’ localizable properties. Works like that – instead of a single list with all localizable properties for entity, there’s now a dict; firstly, it contains a string-key equal to the current top-level entity’s tag (“elements”, “recipes”) with an associated list that contains all entity’s localizable properties. Then it contains other keys-property names and their respective localizable properties (ie “slot” -> label, description, “slots” -> label, description). When checking whether a property is localizable or not, it also checks whether it’s a sub-entity property and if it is, it iterates thought all of sub-entities’ properties, checking again, and possibly again, etc.

The dict flattens the nesting – ie if there’s a structure like “entity type A contains a localizable sub-entity B that contains a localizable sub-entity C” all A, B and C will be in the dict on the same level.This theoretically will cause problems in case, say, an entity has a property “slot” and that slot has a property “slot” too, and the latter property contains another type of entity that has different localizable property. Sounds fairly unrealistic, but give it a few years.

2) That still doesn’t solve the awkward thing about links that they don’t have labels/descriptions and thus can’t really be localized this way (unless again we localize them entirely which again opens us to a possibility of all kinds of insidious things). The best I can think of for now is to add unused pseudo-properties to them (like label/description/slots) so they are localized, and then flush them manually into the recipe.

on The Long and the Devil

Post a Comment