What happens to a gem, a rune or an enchant scroll when the item holding it leaves an equipped slot. Three code paths take a piece out of a slot and all three must obey one contract. Until 0.45 one of them did not, and it destroyed 41 stones for a live player in a single click. Companion page: Equipment & Auto-Optimize.
A piece of gear or a creature tag can carry three kinds of socketed item. How many of each it holds depends on its grade, never its tier: a Worn piece has no sockets, a Rare piece holds 1 gem, 1 rune and 1 scroll, a Void piece 3 / 3 / 3 (codex/grade_ladder.csv; the full ladder is on Grade).
| Socketed item | What it does | Taking it out again |
|---|---|---|
| Gem | Adds a flat amount of the piece’s own stat. Applies the moment it is placed. | Comes back to your bag intact whenever the piece leaves its slot, or when you swap another gem in. |
| Rune | Adds a percentage of the piece’s stat (“increased”). Applies the moment it is placed. | Same as a gem: it returns loose to the bag. |
| Enchant scroll | Rolls an enchantment onto the piece. The scroll’s tier sets the band the number is drawn from and its grade how high in that band it tends to land; the server makes the draw. Tables under the scroll rework on Equipment. | Never. A scroll is bound to the piece it was applied to: it stays on it through unequip, class changes and grade promotions, and the only way to change it is to apply another scroll over it, which destroys the old one. |
Everything below this line is the implementation record: the ownership contract the three unequip paths obey, the socket key, the 0.45 incident that produced the contract, and the exact order a scroll is applied in.
| Rule | Meaning |
|---|---|
| Gems and runes are refunded LOOSE | They come back to the bag as their own items, one row each, marked unsocketed. They do not travel with the piece. |
| An enchant scroll is NOT refunded | It rides with the item, folded onto that item's own bag record as .socks. A scroll is bound to the piece it was applied to and unfolds when the piece is equipped again. |
| The socket entries are deleted | Every key under that item's prefix is removed from sockOv. A stone is never left addressed to a slot that no longer holds its item. |
| Bag room is checked FIRST | invRoomFor() is asked before anything moves, counting the item plus every gem and rune coming back. The check is 1 + gemRuneCountAt(gi) per occupied slot — scrolls are excluded on purpose, because they need no room of their own. |
| A refusal is reported upward | The path returns false rather than half-completing, so a caller cannot announce a change that did not happen. |
Gems are socket kind 0, runes 1, scrolls 2 and above. Every refund branch in the
code is written as ki < 2 versus ki >= 2, so the split is that one comparison in all three
places — not three separate opinions about which items are refundable.
A socketed stone is stored in sockOv under a single flat string:
| Part | Source | Example |
|---|---|---|
| Item identity | optIdentOf() — sk|name|grade|tier | sk|Exalted Signet|Rare|7 |
| Instance | @ plus the equipped slot index | |@2 |
| Socket | kind and position | |0-1 |
The @gi half exists because the identity half is not unique: two copies of the same piece at the same grade
and tier produce the same identity, and before the instance suffix they shared one set of sockets — the second copy
displayed the first copy's gems. claimLegacySocks() migrates any surviving flat key to its own instance,
lowest slot index first, and is idempotent, so re-adopting a save claims to exactly the same keys.
The key is deterministic, and that is what makes a mistake survivable. Nothing random is stored in it. The same item, at the same grade and tier, put back into the same slot index, produces a byte-identical key.
| Path | Function | Reached from |
|---|---|---|
| Manual unequip and swap | equipItem() | The equipment screen. The leaver refunds and folds; an arriver carrying .socks unfolds them into the new key |
| Auto-optimize | optApply() | OPTIMIZE. Every displaced piece takes the same route, and stones evicted from the plan return loose |
| Changing class | switchClass() | Strips all eight character gear slots at once (gi 0–7: Main Hand, Off Hand, both Accessories, Head, Chest, Gloves, Feet). Creature Tags are not touched |
A grade promotion is deliberately not on this list: an equipped piece upgraded in place keeps its stones, because
optMigrateSockKeys() rewrites its keys from the old identity to the new one. The grade is part of the key, so
without that migration a promotion would strand its own sockets.
switchClass() was a third, bare copy of the unequip logic. It pushed the naked item to the
bag and did nothing else: no refund, no fold, no key deletion, no room check. Every stone in all eight slots was left
addressed to a slot that no longer held its item. A player lost 41 stones in one click and filed it three
times before the cause was found.
There are two doors into that function and only one of them warned. The SWITCH CLASS button on the
equipment screen raises a confirmation; equipping a class soul from the soul bag
(itemDetEquip → equipClassSoul → switchClass) raises none at all. That is
why the repair had to live inside switchClass — patching either call site would have left the
other one destroying stones.
Two more defects sat on the same lines. An emptied slot holds a truthy {name:''}, and the
old read fell back to the implicit starter piece whenever the override said empty — so every switch after the first
minted another copy of it, unbounded. And with no room check at all, the path could push the bag past its own cap.
Fixed in 0.45. Gates: tools/probe_classswap_sockets.py (gem, rune and scroll all lost before,
PASS after), tools/probe_classswap_dupe.py (bag 2→4→6→8 over four toggles before, flat at 2
after), and tools/probe_soulbag_classequip.py against the reporter's own live save (3/6 before, 6/6 after
— 41 of 41 accounted for: 32 refunded loose, 9 folded onto their items).
Because the key is deterministic, a stone orphaned against an empty slot is not lost:
| Action on the stripped piece | Orphaned stones |
|---|---|
| Re-equip it — same name, same grade, same tier, same slot index | Recovered. The key regenerates identically and the stones reappear in their sockets |
| Grade it first, then re-equip | Destroyed. The grade is part of the identity, so the new key does not match and nothing migrates an unequipped piece's orphans |
| Dismantle it | Destroyed. The item that owned the key is gone |
Anyone triaging a report of missing stones should say this before saying anything else, because the window closes the
moment the player tidies their inventory. enforceSockStats() reports mismatched sockets and deliberately
never writes to sockOv or the bag, so a sweep cannot close the window either.
Both class-switch confirmations told the player the exact opposite of what the code did. The equipment screen promised that gems, runes and enchants stay socketed; the class soul's detail panel promised sockets and enchantments travelling with their items. Neither was ever true of that path, and that sentence is the reason the player felt safe clicking it — three times.
Any UI text that promises what happens to a player's items must be checked against the code path it describes, not against what the feature was meant to do. Both strings now name the real behaviour: the gear returns to the inventory, gems and runes come back loose into the bag, and each piece keeps its own enchant scroll.
Build 0.47. The auto-optimizer values a rune in its pool with runePct(st2.it).
it is only set on bag-sourced pool entries. For a stone that is already socketed there
is no it, so runePct received undefined and fell through to its
no-curve fallback — grade defaulted to Worn, tier defaulted to 1
— and returned the same constant for every socketed rune in the pool.
They all tied. A tie falls through to the stable pre-sort, and the pre-sort is tier-then-grade. So the optimizer was literally ranking runes by tier and rarity — the two things a rune's value is not made of. A rune's contribution is a percentage read off its own curve; tier and grade feed that curve but do not order it.
Measured consequence on a real save: an Awakened Rune of Ruin T7 Rare worth 19.8% was EVICTED, while an Elder Rune of Ruin T10 Worn worth 18% was socketed three times. The higher tier and the longer name both lost to the lower number, exactly as the fallback dictates.
Gems were never affected, because a gem's valuation already reads name, tier and grade off the
entry itself rather than through an it that may not exist. That asymmetry is why an earlier gem-side fix
passed its checks and this survived underneath it: the two stone kinds were not being valued by the same code, so
proving one correct proved nothing about the other.
Recorded here in one place because the optimizer, the refund contract and the socket UI all depend on it:
| Fact | Detail |
|---|---|
| Socket key | sk|<name>|<grade>|<tier>|@<slot>|<ki>-<j> — the flat form of the three-part table above. |
ki convention | 0 = gem, 1 = rune, >= 2 = scroll. The same comparison the refund branches use. |
| Scrolls never move | No optimizer pass, fill or re-deal relocates a scroll. It rides its item and unfolds back into the item's own key. |
| Socket capacity | Comes from SLOTG, keyed on the host's GRADE, not its tier — Void 3/3/3, Rare 1/1/1. This is why a low-tier Void host can out-earn a high-tier Rare one. |
statFits | The stone's stat must match the host's stat, so an Attack gem cannot enter Defence armour. Every fill and every re-deal is bounded by this before value is considered at all. |
lvlBlock (2026-08-25; LIVE on release since 2026-09-01, 0.65) | A gem, rune or scroll carries items.csv Level_Req = (tier−1)×10 and must not exceed the level of its host's wearer — the account level for character gear, the creature's level for a tag. The socket picker greys and refuses it (before any consume or roll), the optimiser's fill skips it, and one already socketed above level is inert (red ILLEGAL cell, tip names the level) until the wearer reaches it. Nothing is ever unsocketed on the player's behalf. Detail on Equipment. |
Status: the attempt-key and flush rules below are LIVE on release since 2026-08-16 (0.52); written 2026-08-14 on dev. A gem or a rune carries no chance and applies in the same tick it is picked. A scroll waits for the server, because its enchant is a chance outcome and chance belongs to the server. The exact order matters, because a scroll is the one socketed thing that is destroyed on replace and can never be removed once applied:
host could not serve as that key.unsettled decides nothing and costs nothing.
The scroll is not consumed; the bench holds and forces a save so the next click gets a real draw.Client: askEnchant() and the socket picker's onPick /
onSockConfirmYes in play/app.js. Worker: the /api/roll
enchant branch and RK_TARGETED in src/worker.js.
Status key: every entry dated on or before 2026-11-02 is LIVE on release (0.92, 2026-11-02); an “On dev” or “held for release” note inside one of those entries is the status on the day it was written.
2026-11-03 — codex review (dfmc-client lane codexreview-a; D 2026-11-03 “review the codex, clean it up, update whatever needs updating”). WIKI ONLY. The page opened straight into the ownership contract and the 0.45 post-mortem, with nothing a player could read first. New section Sockets at a glance ahead of the implementation record: the three socketed kinds, what each does and what happens when it comes out, the socket counts per grade bound to codex/grade_ladder.csv via csv-bind, the stat-match rule, the level requirement, the unequip / promotion / dismantle outcomes. The level rule’s status was still “ON DEV” from 2026-08-25; it has been live since 0.65. Status key added above the changelog. No mechanic changed.
2026-10-23 — a socketed scroll keeps its enchant roll (dfmc-client lane loadout-bugs, server). On dev. The /api/roll enchant draw answered the enchant and its value but kept it only by attempt; the sock row then seated the server’s bag record of the scroll (name, grade, tier, uid) and the client adopted a socket that had lost its roll. The server now writes the draw on the scroll’s own record (ench_rec, by the scroll’s uid; a later draw on the same scroll replaces it) and the seated record carries it. Gate tools/probe_loadout_srv.py.
2026-10-23 — one socket, one save (dfmc-client lane loadout-bugs, client). On dev. Applying a scroll flushed before the commit that queues the loadout row, so the socket claim and the move went out on two saves and the scroll blinked out of the socket between the two answers. The flush now waits for that commit: one save, one answer. Gate tools/probe_loadout_srv.py.
2026-10-23 — hardcode-sweep: socket counts per grade read codex/grade_ladder.csv. The Gem / Rune / Scroll socket table (Worn 0/0/0 → Void 3/3/3) was typed in play/app.js, src/power.js and tools/sim_t4.mjs; it is the Gem_Sockets / Rune_Sockets / Scroll_Sockets columns now, the same counts. See stat-tables.html changelog.
2026-09-30 — review fixes F156, F157 (client, lane P). On dev. A rune whose items.csv row has no curve in its Effect was valued by a hardcoded copy of the retired curve (a quarter to an eighth of the authored one) in the optimiser’s ranking and the socket line. It is now worth 0 and the data warning still fires, so a missing curve shows up instead of hiding behind a number nobody authored. The client’s scroll comment no longer describes the pre-3.5 host-based roll. Gate tools/probe_review_lanep.mjs.
2026-09-30 — review fixes F6-003 and F6-019 (client). On dev. F6-003: the equip window’s socket line for a worn item scanned the socket overrides by the item’s IDENTITY (sk|name|grade|tier|), which is also the head of every INSTANCE key (…|@gi|ki-j) — so a slot with nothing socketed listed the stones of every other worn copy of the same item, and read every stone as a gem. The scan now names which it means: with the slot it is an INSTANCE scan of that slot’s own key; without one it reads legacy identity keys only and never an instance key. F6-019: a scroll’s answer arrives up to 10 s after the click while the equip window stays usable, and it was applied to whatever item sat in the slot when it landed. The click now records the aimed socket’s instance key and the worn item’s uid; if either changed, nothing is consumed, the player reads “THE ITEM LEFT ITS SLOT — THE SCROLL IS UNTOUCHED”, and the draw stays with the server for that socket (as a cancelled overwrite already does). Gates tools/probe_f6_003.py 1/5 → 5/5, tools/probe_f6_019.py 1/5 → 5/5.
2026-09-28 — a socketed stone keeps its identity, and the server keeps up with it (Q14 “chase it”, D; auto roll_unanchored / sock_claim_bad). SHIPPED IN 0.87, worker half included. The flag “grade sock:sk|Tyrant’s Ruby Warstaff|Void|10|@ claims Abyssal” is not a Void item claiming Abyssal: the socket key carries the host’s grade (Void), and Abyssal is the stone’s own — an Elder Rune of Ruin with no uid. On D’s save every one of the 72 socketed gems and runes carried no uid while every bag gem and rune did. Three causes, all fixed:
(1) the stone lost its uid on the way in and on the way out. Auto-Optimize seated a bag stone as a fresh {name, tier, grade} record, and every refund path (an equip swap, unequip, the optimiser’s gear-out and its evictions) handed stones and gear back the same way. A stone now moves as itself, uid and origin included; a piece split off a stack carries none (the uid names the stack, which stays behind).
(2) only the two hand-socketing sites told the server where a stone went. The optimiser, an equip that unfolds a piece’s scrolls onto its new slot, a host promotion (the key carries the host’s grade) all moved stones silently, so the items ledger kept the old key and the every-save socket walk flagged the stone forever (state=socketed). One check now watches the sockets: any uid that lands on a new key is claimed, once.
(3) the ledger never learned the grade of a stone upgraded in its socket. The server re-reads the grade of what sits in the bag every save, but not what is socketed or worn, and a won roll advanced only the per-position anchor. Moved to another socket, the stone’s first roll re-anchored to that stale row and the honest claim was refused — the client then adopted the lower grade it was told. A won roll now moves the ledger row too (upward only). Gates: tools/probe_mintaudit.py 1/9 → 9/9 and tools/probe_ledgergrade.mjs 2/4 → 4/4. Stones already socketed without a uid stay grandfathered until they next pass through the bag; that backfill is a separate call.
2026-09-16 — a scroll’s socket cell is labelled with the stat it rolled, never the slot’s sample piece (#907, D). ON DEV (0.81). “The scroll on the final slot just rolled with DEF written on it. It is an attack item and should only ever roll attack.” Traced end to end: the roll was right and the label was wrong. Since #96 the roll door resolves the stat from the worn piece and the server overrides the claim with the host’s own items.csv stat, so a Transcendent Fang can only draw Creature Attack — and had the stored roll been Defense the cell would have read ILLEGAL, not DEF. The DEF came from the socket cell’s abbreviation, which asked the stone for a stat identity and fell back to the slot’s own. A scroll has no stat identity (it is dice, not a stat — bug #3), so every flat scroll roll fell through to the slot’s design-sample piece, and Tag B’s sample is a Creature Defense carapace. Same fallback family #96 pulled out of the roll, still alive in the label; the tooltip on the very same cell was already printing +N Creature Attack from the roll. The cell now takes the stat off the stone’s own record — the server’s rolled stat, else the stat in its rolled text, else the stone’s catalogue stat (gems, runes), else the worn host’s real stat — and reads the slot sample only when nothing is worn. The roll path is untouched.
2026-09-09 — every socketed stone can be told apart from every other copy of itself (#799 / #792, D). LIVE on release since 2026-09-09 (0.73). A gem or rune socketed before the item ledger existed carries no identifier of its own, so the server could only ever refer to it by name — and one name covers every copy you own, at every grade. That is why a socket’s grade memory could speak for the wrong stone after an auto-optimise pass, and why the ledger had nothing to answer with when asked what a socketed stone actually is. A one-off pass now gives each of those stones its own identifier and opens one ledger line for it, at the grade the server already stores for it, on its own dated record so it stays distinguishable from an item that was earned. It is an opening balance: nothing is granted, nothing is created that the server was not already holding. A socket naming an item the game does not author gets nothing, and a stone the ledger has already recorded as spent is never brought back. Once a stone has its own line, the server holds that stone’s grade and refuses a claim that disagrees with it. Gate tools/probe_sockmig.mjs 9/9, red 1/9.
2026-09-02 — a socketed gem is valued by what it pays (D ruling: “take into consideration the total final stats of the item”). LIVE on release since 2026-09-03 (0.69). The auto-optimiser ranked a socketed gem by a grade-formula lookup while the engine pays the value stored on the stone; runes have ranked off their own curve since the 0.47 entry above, so the two stone kinds were again valued by different code. A gem whose stored value and grade disagreed was called the weakest stone, evicted for a lower-paying spare, and the run ended weaker and was refused. Now a socketed gem ranks by its stored value and a bag gem by what it will pay once socketed; the optimiser's “a spare looks better” self-check reads the same number. Every gem on D's live loadout already agreed with its grade, so nothing visible changes today; the rule is now the same for both kinds. Gate tools/probe_optpaid.py 5/5 (2/5 before); probe_optgrade re-seeded to pay what its label says, 4/4 (1/4 before).
2026-08-28 — Primordial gets rising bubbles, and socketables get their grade effect (#599, #600). On release. The Primordial background now carries bubbles in its own two colours drifting upward on a seamless loop. It is drawn entirely in the stylesheet, with no element per bubble — this layer appears on every Primordial tile in the inventory, so a node per bubble would be hundreds of nodes of decoration; the upgrade screen can afford them because it draws a single item. Separately, socketed gems and runes in the equipment window now carry the same grade background every other tile has. Both go through the one producer the grade effects have always used, so adding a grade in one place still reaches every screen — which is the entire point, since the recurring fault in this family has been one screen updated and another not. Reduced-motion settings switch the bubbles off with the rest.
tools/probe_rollattkey.mjs 12/12 (8/12 before), probe_rollreplay.mjs extended.runePct(st2.it), and it is only set on bag-sourced pool entries;
for an already-socketed stone it was undefined, so runePct took its no-curve fallback
(grade → Worn, tier → 1) and returned the same constant for all of them. They
tied, and the tie fell through to the stable pre-sort, which is tier-then-grade. Measured on a real save: an
Awakened Rune of Ruin T7 Rare worth 19.8% was evicted while an Elder Rune of Ruin
T10 Worn worth 18% was socketed three times. Gems were never affected because they already
read name, tier and grade off the entry, which is why an earlier gem fix passed while this stayed broken. The key
shape (sk|name|grade|tier|@slot|ki-j), the ki convention (0 gem, 1 rune, >=2 scroll),
the grade-keyed SLOTG capacity and the statFits stat-match rule are recorded above at the
same time.switchClass now refunds exactly as equipItem does, reserves 1 + gemRuneCountAt(gi) per occupied slot before it moves anything, reads the slot override as truth so it mints nothing, and returns false when it refuses. Both confirmation texts corrected. Page created.