Combolandsmod

Anchor catalogue

This mod attaches to type and member signatures inside Assembly-CSharp.dll. A game update can move any of them. This table collects, in one place, what breaks and where to fix it.

Verified against Steam build 24989173 / game v1.0.6 (Unity 6000.0.66f2, Assembly-CSharp.dll sha256 f95343c0…c07253). See generated/fingerprint.json.

Every bound row below resolved against Assembly-CSharp.dll on 2026-09-23, 0 broken, by tests/Combolands.Anchors — and against the running game on 2026-09-22: 34 members, 0 broken. The live pass was not read off a decompiler — reflected out of the live Mono domain by the M0 probe, with Assembly-CSharp, Assembly-CSharp-firstpass and Unity.TextMeshPro all loaded before the first scene.

Bound, and merely observed

Most rows below name a member the mod looks up by name at runtime, or compiles against. Those are bound: if one moves, something stops working, and tests/Combolands.Anchors says which one.

A handful are marked observed. They are real dependencies that no signature can express — an asset layout, a grammar the linter reimplements, a formula copied rather than called, a claim about what one argument gates. Nothing looks them up, so nothing can check them, and a probe that passed would only be answering a question nobody asked. When the game updates, these are the ones a person has to read.

Writing the offline test is what separated the two. Six rows turned out to describe members the mod never touched, including one, P3, that the valuation had deliberately replaced with its own arithmetic — and the row still claimed autoplay depended on it.

Why these anchors are sturdier than a JS bundle’s

The build is Mono with no obfuscation. Namespaces, type names, method names and parameter names all survive into the shipped DLL — there is no minification step to shorten them and no renamer pass to scramble them. An anchor only breaks when Crux actually changes the code, not when they rebuild it.

That also sets the real failure mode: not renaming, but reshaping. If the localisation scaffold is replaced with com.unity.localization, A1–A4 break together and the language patch is rewritten rather than repaired.

Language patch

# Anchor Signature Breaks Fix in
A1 translation choke point Library.Localization.LocalizedStringAsset.GetText() : string every translation i18n/Patches.cs
A2 translation key LocalizedStringAsset.Key : string (public field) every translation i18n/Patches.cs, i18n/Dump.cs
A3 asset-name fallback Entities.Data._BaseData.Name : string (getter) pieces with no LocalizedString show English file names i18n/Patches.cs
A4 string dump Resources.LoadAll<LocalizedStringAsset>("") — i.e. the assets staying under Resources extraction; translation still works i18n/Dump.cs
A5 lookup tables Interaction.StringLookup (ScriptableObjectSingleton) — observed categories, rarities, tile types, quest rewards stay English i18n/Patches.cs
A6 description assembly Entities.Data.StringProcessor — the whitespace split and [/{ handling — observed token rules in lint-locale.py become wrong tools/lint-locale.py

A1 is the one that matters. It is a public no-argument method on a public class and everything else in the language patch is cleanup around it.

A6 is not patched — it is depended on. The linter encodes its parsing rules, so if StringProcessor changes how it splits, the linter starts enforcing a rule the game no longer has. That failure is silent in both directions; re-read the method when the game updates.

Font

# Anchor Signature Breaks Fix in
F1 runtime font creation TMPro.TMP_FontAsset.CreateFontAsset(string fontFilePath, int faceIndex, int samplingPointSize, int atlasPadding, GlyphRenderMode, int atlasWidth, int atlasHeight) all Hangul renders as □□□ i18n/Font.cs
F2 global fallback TMPro.TMP_Settings.fallbackFontAssets : List<TMP_FontAsset> (public static, get and set) same i18n/Font.cs
F3 per-font fallback TMP_FontAsset.fallbackFontAssetTable : List<TMP_FontAsset> (public) Hangul missing on fonts that bypass the global list i18n/Font.cs
F4 dynamic atlas TMP_FontAsset.atlasPopulationMode = AtlasPopulationMode.Dynamic glyphs never rasterise i18n/Font.cs
F5 glyph size TMP_FontAsset.faceInfo (FaceInfo.scale, settable through the property) Hangul renders far larger than the game’s Latin i18n/Font.cs

These live in Unity.TextMeshPro.dll, which moves with the Unity version, not with Crux’s code. It is the anchor group least likely to break on a game patch and the one most likely to break on an engine upgrade.

F1 is not one method. The build carries six CreateFontAsset overloads, and they give the font work two independent routes:

CreateFontAsset(string familyName, string styleName, int pointSize = 90)   ← OS font
CreateFontAsset(string fontFilePath, int faceIndex, int samplingPointSize,
                int atlasPadding, GlyphRenderMode, int atlasWidth, int atlasHeight)
CreateFontAsset(…, AtlasPopulationMode, bool enableMultiAtlasSupport = true)
CreateFontAsset(Font font)                                                 ← ×3 overloads

The file-path pair is the plan: ship the TTF, no dependence on the player having a Korean font installed. The familyName overload is the fallback if loading from a path is refused at runtime, and it would mean falling back to Malgun Gothic on the player’s own machine — a worse look, but not a dead end.

M1 called one and it worked. CreateFontAsset(path, 0, 48, 9, SDFAA, 1024, 1024) returned a Galmuri11 asset in Dynamic population mode, TryAddCharacters added 300 of 300 Hangul syllables with none missing, and the atlas spilled to a second 1024×1024 Alpha8 texture on its own — so F4 and multi-atlas both behave.

F5 is why the patch is legible. TMP_Text sizes each glyph as m_currentFontSize / faceInfo.pointSize * faceInfo.scale, reading faceInfo from whichever asset supplied that glyph — so setting scale on the fallback asset, and only there, shrinks Korean without touching the game’s own text. At 1.0 Hangul overflowed buttons; 0.8 matches.

One thing the table cannot express: F3 must be applied per scene, not once. Only five TMP fonts are loaded at the menu; monogram-extended SDF and THEBOLDFONT-FREEVERSION SDF arrive with the game scene. A single startup pass leaves those two without Hangul.

Cheat

# Anchor Signature Breaks Fix in
C1 built-in cheats Interaction.CheatsHandler, _cheatsEnabled : bool (private field), live in level3 — observed the shortcut; the widget still works cheat/Widget.cs
C2 economy GameState.ScoreController — Score : long, Money : int, Rerolls, Removes, Dismisses, Rewinds : int, Enchant : float (private setters) resource cheats cheat/Economy.cs
C3 economy writes ScoreController.ChangeMoney(int, GamePiece, bool), ChangeScore(long), ChangeRerolls(int, GamePiece, bool), ChangeDismisses(int), ChangeRewinds(int) resource cheats cheat/Economy.cs
C4 run control GameState.GameController.DebugChangeWeeks(int), DebugSetTarget(int), AddExtraWeek() week and target cheats cheat/Run.cs
C5 placement bypass Entities.BuildingController.CanBuildBuildingAt(Building, int, int, out CantPlaceBuildingReason, bool ignoreAllPlacementRestrictions) “build anywhere” cheat/Build.cs
C6 spawning BuildingController.SetCanPlaceNextBuildingAnywhere(bool) the free-placement cheat cheat/Build.cs
C7 unlocks Progression.UnlockStateController.DebugUnlockAllGuilds(), DebugLevelUpAllGuilds() unlock cheats cheat/Unlocks.cs
C8 milestone GameState.MilestoneManager.DebugSetCurrentMilestone(int), EndCurrentMilestoneEarly() milestone skip cheat/Run.cs
C9 shop GameState.ShopManager.DebugShowShop() forcing the shop cheat/Run.cs
C10 achievement gate External.AchievementsHandler.Achieve(Achievement) (private, 1 arg) achievements would submit while cheating cheat/Integrity.cs
C11 singleton access Library.MonoSingleton`1 / Library.SerializedMonoSingleton`1 — Instance, HasInstance every cheat, and autoplay’s reads Singletons.cs
C12 click guard Library.Utils.UiUtils.IsPointerOverUIObject() clicks on the panel also place buildings cheat/Widget.cs

C1 is a dependency on a developer oversight, not on an API, and it works: RightShift+C+L arms it in the retail build, and +G and +B were confirmed in game on 2026-09-22. The cheat menu is debug code that shipped; a build that strips it takes the keyboard shortcuts with it. The widget is deliberately built on C2–C9 instead, so the loss is cosmetic.

C10 is the only anchor whose failure is silent and outward-facing. If Achieve moves, cheats keep working and Steam keeps receiving achievements — so the miss is logged as an error rather than a warning, and it is the one to check first after a game update.

C11 is reached generically: static members of a generic type are per constructed type, so MonoSingleton<ScoreController>.Instance really does reach the live one. HasInstance is checked first every time, because Instance logs a Unity error when the singleton is missing and a panel drawn on the main menu would otherwise write one error per control per frame.

C5’s ignoreAllPlacementRestrictions already being an optional parameter is why a prefix forcing it true is the whole feature. If that parameter is removed, the bypass has to be rebuilt against the method body.

Autoplay

# Anchor Signature Breaks Fix in
P1 the board Environment.MapController.Grid, and on whatever type it returns: Width, Height, GetTile(int, int) all of autoplay autoplay/Board.cs
P2 legality BuildingController.CanBuildBuildingAt(…) (= C5) all of autoplay autoplay/Board.cs
P3 adjacency and range BuildingController.GetBuildingsWhoseRangesOverlapTile(Tile), IsTileInRangeOfCategory(…), IsTileInRangeOfBuilding(…) — observed, the road not taken nothing — see P11 —
P4 placed pieces BuildingController.Buildings : List<Building> valuation quality autoplay/Board.cs
P5 state gate Interaction.InteractionController.CurrentInteractionState, PlacingBuilding, Shopping all of autoplay autoplay/State.cs, autoplay/Shop.cs
P6 the 34 states Interaction.InteractionStates.* — PlacingBuilding.OnEnter, _BuildingUtilityTargetingState.ExitEffectTargetingState() autoplay cannot tell what the game is doing autoplay/State.cs, autoplay/Items.cs
P7 scoring speed CheatsHandler.SpeedUpScoring : bool (public field) unattended runs take real time cheat/Run.cs
P8 the held piece Interaction.InteractionStates.PlacingBuilding._currentlyPlacing (private field) the helper never has anything to suggest autoplay/State.cs
P9 tiles Environment.Tile — X, Y, Type, IsEmpty, CantBuildOn the helper suggests occupied tiles autoplay/Board.cs
P10 piece shape Entities.Building — X, Y, Range, Tag, Categories valuation quality autoplay/Board.cs
P15 board changed Entities.BuildingExtensions.ResetCaches() suggestions go stale until the held building changes autoplay/Overlay.cs
P16 what is on offer UI.BuildingChoiceBar.Choices · UI.BuildingChoiceButton.GameTag scouting stops entirely autoplay/Offers.cs
P17 base stats, no instance _BuildingBehaviour._range · _majorCategory · _minorCategories · GetBehaviourTargetTags() · GetBehaviourTargetCategories() · GetScoreForTag(…) · BuildingBehaviours.BuildingBehaviourDict scouting stops autoplay/Offers.cs
P18 terrain by tag _BuildingBehaviour.GetTileTypesCanBePlacedOn(GameTag) · Environment.Tile.Type scouting points at ocean autoplay/Offers.cs, autoplay/Rules.cs
P20 placing, for real PlacingBuilding.OnUpdate(Vector3, Vector2Int, Tile, bool) · PlaceCurrentBuilding(Vector2Int) · _canPlaceCurrentBuilding autoplay cannot place anything autoplay/Exec.cs
P21 choosing, for real UI.BuildingChoiceButton.OnPointerClick(PointerEventData) autoplay places but never picks autoplay/Exec.cs
P22 is it on the board Entities.Building.Tile autoplay throws whenever the cursor leaves the window autoplay/Exec.cs
P23 rerolling a bad draw UI.ConsumablesPanel.PressRerollButton() · ScoreController.Rerolls autoplay plays whatever it is dealt autoplay/Rerolls.cs
P19 piece name Entities.GamePieceDataHolder.GetDataFor(GameTag) → _BaseData.Name the panel lists tags instead of names autoplay/Offers.cs
P11 range shape Library.Grid.GridDrawingAlgorithms.GetFilledCircle — dx*dx + dy*dy < r*r + r — observed, copied not called every highlight is subtly wrong autoplay/Snapshot.cs
P12 declared targets GamePiece.TargetTags · _GamePieceBehaviour.GetScoreForTag · GamePieceLocalValues.TargetCategories · GetScoreForTargetCategory the helper stops knowing what a building wants and falls back to bare adjacency autoplay/Board.cs
P13 same-type rule _BuildingBehaviour.HasRangePlacementRestriction(GamePiece) the helper suggests tiles the game refuses autoplay/Board.cs, autoplay/Rules.cs
P14 rule escape hatch CanBuildBuildingAt(..., bool ignoreAllPlacementRestrictions) gating only the same-type check — observed the helper has to trust a stale cache autoplay/Board.cs

P12 is what makes a suggestion worth following. Everything else in the valuation is a proxy; these four are the game telling us, in its own numbers, what a building is looking for. If they move, the helper keeps working and quietly gets much worse — which is the failure mode worth watching for, because nothing breaks.

P14 is a dependency on a precise fact rather than a general one: that argument gates one if and no others. A refactor that widened it to cover more checks would silently make the helper suggest illegal tiles, and it would still compile, run and look fine. Re-read CanBuildBuildingAt after a game update.

P17 is a dependency on private fields, which is unusual here and deliberate. The public accessors all take a GamePiece, and the overrides dereference it — so asking them about a building that does not exist yet means passing null and hoping. The fields are the base values, which is exactly what a building not yet on the board is worth.

P18 is the reason scouting is honest rather than approximate about terrain: it takes a tag. Nineteen behaviours override CanBeBuiltOn with rules that need an instance, and those stay unevaluated until the player picks the building up.

P11 is the one anchor this mod copies rather than calls. Asking the game whether each of 1,188 tiles is in range of each building would be tens of thousands of reflection calls a frame, so the shape is reproduced in Piece.Covers and pinned by tests. That makes it the one place where the game can change without anything failing — the helper would simply start drawing confidently wrong highlights. Re-read GetFilledCircle after a game update; nothing else will tell you.

P1 and P4 are read-only and public. P5 and P6 are read-only too, and deliberately: ChangeToState is right there, and autoplay never calls it. Driving the state machine would mean knowing when a transition is safe, which is not expressible as a signature and not discoverable from outside. So the loop waits for a state instead of causing one, and every action it takes is a method that state already exposes.

P20 is the one anchor whose failure is destructive rather than merely wrong, and that is why it is used at all. The obvious alternative, BuildingController.InstantiateAndBuildBuildingAt, would keep working after a refactor and quietly stop doing half the job. A missing PlaceCurrentBuilding stops autoplay dead, which is the failure worth having.

The simulator

The helper stopped approximating the score and started computing it, which means a new group of anchors: the ones the rule dump reads. They are read once, into generated/rules.json, so a break here does not stop autoplay - it stops the next dump, and the mod falls back to the old proximity valuation with a line in the log saying so.

# Anchor Signature Breaks Fix in
M1 the score itself _BuildingBehaviour.GetScorePreview(Building) — the shape of it, which is reproduced rather than called the simulator is wrong and nothing says so autoplay/sim/Preview.cs
M2 which neighbourhood _GamePieceBehaviour._scorePreviewMode (protected field) · ScorePreviewMode every building is scored over the wrong tiles autoplay/sim/Dump.cs
M3 how often it pays _GamePieceBehaviour._cooldownParam (protected field) · CountIsReadyToActivate slow buildings are overrated by their cooldown autoplay/sim/Dump.cs
M4 what it pays for GetBehaviourTargetTags() · GetBehaviourTargetCategories() · GetBehaviourTargetRarities() · GetBehaviourTargetTileTypes() · GetScoreForTag · GetScoreForRarity · GetScoreForTileType · GetScoreParam the dump is empty and the helper falls back autoplay/sim/Dump.cs
M5 the multiplier Entities.GamePiece.Multiplier a levelled board is valued as a fresh one autoplay/Board.cs
M6 the same-type rule, statically _BuildingBehaviour._hasRangePlacementRestriction (protected FIELD, not the method) the simulator proposes tiles the game refuses autoplay/sim/Dump.cs
M7 activation _GamePieceBehaviour._activationCount · _canBeActivated (protected fields) trigger cascades are valued at nothing autoplay/sim/Dump.cs
M8 which buildings activate the eighteen behaviours that call TriggerController.AddOnActivatedTrigger — observed, a list read from the game’s code same, silently autoplay/sim/Dump.cs

M1 is the anchor this whole feature rests on, and it is the one no probe can defend. GetScorePreview is reproduced in sim/Preview.cs, not called - calling it would need a live building on a live tile, which is the thing a planner cannot afford. So the method can keep its name, change its arithmetic, and every check here will pass while every number the helper prints is quietly wrong. Re-read it after a game update; ten tests pin the reproduction, and nothing pins the original.

Three of these were read wrong on the first attempt, all in ways that produced a plausible-looking rule set:

GetBehaviourTargetCategories returns List<TargetCategory>, and TargetCategory is a struct of public fields — { TargetNumber, GamePieceCategory, Score } — not an enum. Converting the element itself throws, so the first dump recorded a zero for every category in the game, which is most of the scoring in it. This is the same trap as P12 and it was fallen into the same way.

HasRangePlacementRestriction ends at a field. Everything above that return is an exemption granted by run state — an equipped Fishing Net, an adjacent Plaza — so calling it with no building to ask about both throws and would have answered about a run rather than about a building. M6 reads the field.

M8 is the only thing in the dump not read out of the game, because the game does not record it anywhere a reader can reach: a behaviour activates its neighbours by calling AddOnActivatedTrigger from its Activate override. No field says so and no signature implies it. So it is a list of eighteen names, read out of the game’s code on 2026-09-23 against v1.0.6, and the dump checks every name still exists — a renamed building is reported rather than silently dropped.

Being wrong about it costs differently in each direction. A building missing from the list is valued at nothing extra, which is safe. A building wrongly on it is over-valued, which is the direction that makes a milestone plan miss — so when in doubt it is left off.

Some buildings score without offering a preview. Woodcutter pays eighty a tree over range two and sets no _scorePreviewMode, because it pays on a cooldown rather than every week. Reading that as “scores nothing” would drop some of the best pieces in the game, so the dump infers InRange, marks the piece approximate, and records reachInferred — a labelled guess rather than a silent one.

Autoplay’s screens

# Anchor Signature Breaks Fix in
S1 milestone summary UI.MilestoneScreen.MilestoneScreen.WaitingForClick · ProcessClick() autoplay stops at every milestone autoplay/Screens.cs
S2 modal dialogs Shared.UI.MessageDialog.IsShown · Close() autoplay stops at the first dialog autoplay/Screens.cs
S3 start-of-milestone pack UI.StartOfMilestonePack (click handler) autoplay stops after every milestone autoplay/Screens.cs
S4 choosing a council request UI.Quests.CouncilQuestOptionButton.Quest · QuestSelectionPanel.SelectQuest() · _selected · Confirm() · PressRerollButton() same autoplay/Quests.cs
S7 claiming a council reward UI.Quests.CouncilQuestTextArea._currentQuests · _textBlock · PressHeaderButton() · PressClaimRewardButton() · PressSkipRewardButton() · QuestCompletedAnim (active) the run ends at the next milestone autoplay/Quests.cs
S5 pack contents UI.PackSelectionPanel._options · SkipSelection() · UI.Shop.ShopItem.OnPointerClick(…) autoplay stops on an open pack autoplay/Screens.cs
S6 leaving the shop UI.ShopPanel.Exterior (a FIELD) · FinishShopping() · ShopExterior.IsShown · _currentSkipReward · SkipShop() · EnterShop() autoplay stops at the shop autoplay/Shop.cs

S6 carries a precondition the game does not check for itself. SkipShop dereferences _currentSkipReward on its first line and nulls it on its last, while IsShown is cleared later by a coroutine — so calling it twice throws, and once it has thrown the loop never gets past Screens.Handle again. That is why the mod reads the private field before calling, and why any successful screen action is followed by a settle.

S7 is the most expensive anchor in this table to lose, and the reason is in MilestoneManager.CompleteCurrentMilestoneRoutine: with a reward outstanding it switches to ClaimingQuestReward, which disables all input, and then spins on while (IsQuestRewardWaitingToBeClaimed) with no timeout and no escape. An unclaimed reward does not cost a reward - it ends the run where it stands.

Three preconditions sit on that one press, and the mod checks each:

Choosing has one trap of its own. QuestSelectionPanel.SelectQuest toggles, and Confirm() dereferences _selected with no null check - so selecting the same option twice and confirming is a null reference. The mod reads _selected back before confirming.

One anchor here is an absence: nothing in the game calls ProceedQuestsOfTypeByAmount for CouncilQuestType.FinishEarly. The type is in the enum with no progress hook anywhere, so a request of that type cannot be completed by anyone, and Quests.Avoid lists it alongside the ones autoplay merely declines to do.

The hazard that is not in the table

Entities.Building redeclares GamePiece.Behaviour with new, and the game does this in more than one place. Type.GetProperty(name, FlattenHierarchy) finds both declarations and throws AmbiguousMatchException — which, inside a Log.Guard, disabled the placement helper for a whole session while looking on screen like it simply did nothing.

Every lookup in this mod therefore goes through Reflect.cs, which walks the hierarchy from the most derived type with DeclaredOnly. Do not reach for GetProperty, GetField or GetMethod directly. The shapes that broke it are pinned in tests/ReflectTests.cs.

Two more traps of the same kind, both of which disabled a feature in silence:

A destroyed Unity object is not null. The == overload that makes it look null is chosen by the static type, and reflection returns object. Use Alive.Is(...), never x == null, on anything that came out of the game.

TargetCategory is a struct of public FIELDS, not properties. Asking Anchors.Property for GamePieceCategory found nothing, and every category-targeting building scouted as though it wanted nothing - a warning in the log and a silently worse answer on screen. When a member is not where you expect, check which kind it is before assuming the name moved.

What watches this file

Two things, at different costs.

tests/Combolands.Anchors reads an installed Assembly-CSharp.dll as metadata and checks every bound row in one pass. It launches nothing, constructs no game type and runs no static initialiser — MetadataLoadContext reads the tables and stops there. Forty anchors in about fifty milliseconds, and the failure names the row:

P17 base stats, no instance no longer resolves:
  Entities.BuildingBehaviours._BuildingBehaviour._minorCategories - is a PROPERTY now, not a field
See docs/ANCHORS.md row P17 for what this breaks and where to fix it.

It binds through the same Reflect.cs the mod uses, which matters: a check written with GetProperty would miss the shadowing trap that Reflect exists for, and would also pass on overloads the mod cannot actually bind. Two rows were found that way — GetTile and IsTileInRangeOfBuilding both have several candidates of the same arity, and only the first is disambiguated by parameter type at runtime.

The game is found in the usual Steam locations, or wherever COMBOLANDS_DIR points:

COMBOLANDS_DIR="C:/Program Files (x86)/Steam/steamapps/common/Combolands" dotnet test

With no game it skips rather than passes, because a green run that checked nothing is worse than no test. The checks that hold this file and the catalogue to each other still run — a row with no catalogue entry is an anchor nobody verifies, and an entry with no row is a failure nobody can look up.

The M0 probe is the stronger check and the more expensive one. It runs as a MelonMod inside the live game, so it also proves the loader still boots and the assemblies are reachable from a mod’s perspective — things a static reflection pass cannot tell you. It writes the table above with the resolved signature beside each row, which is how you see that a method survived but changed shape.

Run the static test after every game update, and re-read the observed rows by hand — nothing else will. Run the probe when the static test disagrees with you, or when the loader is what you suspect.