# Data Schemas Reference

The shape of every file in `data/`, loaded at startup by `DataRegistry` (see
[DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)).
Fields are read through `.get("key", default)`, so most are optional; the tables
below mark the fields consumers actually read and give a real excerpt per file.
`tools/validate_data.gd` enforces the referential rules noted here — run it after
any edit.

> Meta note: `materials.json` must not contain keys starting with `_` —
> `GameManager.compute_completion_pct()` uses `materials.size()` as a
> denominator, so a `_comment` there would skew the completion percentage. Other
> files use a `_comment` key freely; `DataRegistry` and the validator strip or
> skip it.

## materials.json

Top level is `material_id -> Dictionary`. `MaterialDef.from_dict()` captures the
core physics fields; the economy and generation fields (`trade_value`,
`durability_cost`, `material_tier`, `yield_weights`, `first_depth`) are read
directly off the raw dictionary.

| Field | Type | Meaning |
|---|---|---|
| `id` | String | Material id (matches its key). |
| `display_name` | String | UI label. |
| `source_type` | String | `terrain`, `forest`, `refined`, `supply`, `machine`, `processed`, `crafted`, or `exotic`. |
| `dig_time` / `gather_time` | float | Base action seconds. |
| `terrain_hp` | float | HP to dig out a tile of this material. |
| `wall_hp` | float | HP of a wall built from it (`0` = not a wall material). |
| `base_chew_resistance` | float | Maw resistance multiplier. |
| `is_wall_material` / `is_crafting_material` / `is_fuel` | bool | Category flags. |
| `is_machine` | bool | Present on machine-body pseudo-materials. |
| `trade_value` | int | Villager-economy anchor value. |
| `durability_cost` | float | Tool wear per dig (terrain materials). |
| `material_tier` | int | Overreach tier — a pickaxe one tier under pays 4x durability. |
| `first_depth` | int | Shallowest depth the material appears. |
| `yield_weights` | Array[int] | Weighted roll for variable yield (e.g. `[94, 5, 1]`). |
| `max_stack` | int | Inventory stack cap (used by consumables like `dynamite`, `mining_charge`). |

```json
"stone": {
  "id": "stone", "first_depth": 27, "display_name": "Stone",
  "source_type": "terrain", "dig_time": 5.0, "gather_time": 5.0,
  "terrain_hp": 50.0, "wall_hp": 150.0, "base_chew_resistance": 2.0,
  "is_wall_material": true, "is_crafting_material": true, "is_fuel": false,
  "trade_value": 3, "durability_cost": 1.0, "material_tier": 2,
  "yield_weights": [93, 5, 2]
}
```

> The Black Hollow feature adds `resonant_ore` (a Black-Hollow-exclusive
> `source_type: exotic` crafting material — never home-mined or Astrolabe-seeded)
> and `hollow_vault_block` (`is_wall_material`, `wall_hp: 9000`,
> `base_chew_resistance: 10.0` — the toughest wall in the game). See
> [Black Hollow](/docs/underroot/black-hollow).

## tools.json

Top level is `tool_id -> Dictionary`. `ToolDef.from_dict()` captures speed and
allow-lists; `max_durability`, `max_material_tier`, `family`, `tier`, and
`tool_type` are read directly (e.g. by `ToolState`).

| Field | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `description` | String | Identity and UI text. |
| `dig_speed_multiplier` / `gather_speed_multiplier` | float | Action-speed multipliers. |
| `allowed_materials` | Array[String] | Diggable material ids (empty = anything). |
| `allowed_resource_nodes` | Array[String] | Harvestable node types (e.g. `"tree"`). |
| `max_durability` | int | Durability; `-1` = infinite (hands). |
| `max_material_tier` | int | Highest tier the tool digs without overreach. |
| `recipe_id` | String | Recipe that crafts it. |
| `family` / `tier` | String / int | Upgrade family grouping and tier. |
| `tool_type` | String | `"processing"` or `"well"` route `ToolState.add_tool` differently. |
| `storage_upgrades` | Array | Silo/tower upgrade tiers (`cost`, `bonus`). |

```json
"pickaxe": {
  "id": "pickaxe", "family": "pickaxe", "tier": 1, "display_name": "Pickaxe",
  "dig_speed_multiplier": 2.0, "gather_speed_multiplier": 1.0,
  "allowed_materials": ["dirt", "clay", "stone", "coal", "iron_ore", "sulfur", "dense_stone"],
  "allowed_resource_nodes": [], "max_durability": 80,
  "recipe_id": "pickaxe", "max_material_tier": 3
}
```

## recipes.json

Top level is `recipe_id -> Dictionary`. `RecipeDef.from_dict()` captures the
craft fields; `category` and the unlock gates are read directly by
`CraftMenu` and `CraftingUnlockManager`.

| Field | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `icon` / `description` | String | Identity and UI. |
| `category` | String | One of `tools`, `structures`, `machines`, `components`, `processing`, `village`. Any other value renders nowhere. |
| `input_materials` | Dictionary | `material_id -> amount` (all amounts must be `> 0`). |
| `output_id` | String | Must be a real material, tool, or machine. |
| `output_amount` | int | Yield per craft (must be `> 0`). |
| `craft_time` | float | Base seconds before `crafting.speed_multiplier`. |
| `required_layer` | String | Layer id that must be reached to unlock (`""` = none). |
| `required_machine` | String | Processing machine that runs it (e.g. `"smelter"`). |
| `unlocked_from_start` | bool | Granted at run start. |
| `unlock_requires_machine` | String | Machine type whose placement unlocks it. |
| `unlock_requires_astrolabe_uses` | int | Ritual count required to unlock. |
| `unlock_requires_champion_skill` | String | Champion-class skill gate (class-landmark recipes) — the recipe is visible only to that skill's champion. |
| `unlock_requires_skill_node` | int | Minimum champion skill node for the gate above (the landmarks gate at node 3). Re-checked on `EventBus.skill_leveled`. |
| `unlock_requires_schematics` | int | Black Hollow schematic-fragment count required (account-wide, `SaveManager.schematics_found()`). Gates the Hollow Crucible build recipe (`5`); re-checked on `EventBus.black_hollow_schematics_changed`. |

> The validator rejects a recipe that has both `unlock_requires_astrolabe_uses`
> and `unlocked_from_start` — the start grant bypasses the ritual gate.

The class-landmark recipes (`smokehouse`, `mead_hall`, `trading_post`,
`deepdrill`, `grand_anvil`, `bulwark`) carry the champion-skill pair, e.g.
`"unlock_requires_champion_skill": "digging", "unlock_requires_skill_node": 3`.
`CraftingUnlockManager._class_node_gate_open` resolves the gate. See
[Skill Tree and Classes](/docs/underroot/skill-tree-and-classes).

> A recipe id doubles as a `gate_recipe` handle for `trades.json → tool_offers`:
> a scout offer with `"gate_recipe": "iron_axe"` stays hidden until
> `CraftingUnlockManager.is_unlocked("iron_axe")` (its inputs have been seen).

```json
"smelt_iron": {
  "id": "smelt_iron", "display_name": "Smelt Iron", "category": "processing",
  "input_materials": {"iron_ore": 3, "coal": 2},
  "output_id": "iron", "output_amount": 1, "craft_time": 35.0,
  "required_layer": "", "required_machine": "smelter"
}
```

## machines.json

Top level is `machine_type -> Dictionary`. Two broad kinds share the file:
drills/excavators (fuel-burning miners, `is_processor: false`) and processors
(`is_processor: true`, with a `recipe_id`). Structures and special buildings
(Astrolabe, apothecary, pump) carry extra flags.

| Field | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `description` | String | Identity and UI. |
| `footprint_w` / `footprint_h` | int | Tile footprint. |
| `is_processor` | bool | Runs `recipe_id` when true. |
| `recipe_id` | String | Recipe consumed (processors). |
| `selectable_recipes` | Array[String] | Multi-recipe processors (e.g. glazing kiln, Grand Anvil). |
| `recipe_picker_label` | String | Per-machine processor recipe-picker header (e.g. `"FORGE OUTPUT"`). |
| `work_rate` | float | Dig/process rate. |
| `max_fuel` | float | Fuel tank capacity. |
| `fuel_material` | String | Fuel id (e.g. Astrolabe's `strange_root`). |
| `durability_min` / `durability_max` | int | Placed-durability roll range. |
| `repair_cost` / `repair_materials` / `repairable` | int / Dictionary / bool | Repair economy. |
| `max_placed` | int | Placement cap (`0` = single-use/explosive; falls back to a default when omitted). |
| `dig_width` / `directional` | int / bool | Excavator width and rotate-on-place. |
| `is_structure` / `is_pump` / `is_explosive` / `requires_well` | bool | Special behaviours. |
| `process_section` | String | UI grouping (e.g. `"buildings"`). |
| `no_fuel` | bool | Landmark buildings/structures that draw no fuel (Trading Post, Mead Hall, Bulwark). |
| `support_bonus` | int | Flat village-support bonus while placed (Mead Hall). |
| `gold_per_pop` | float | Passive gold per population per interval (Trading Post). |
| `food_per_day` / `food_target` | float | Passive food output / cap (Smokehouse). |
| `wall_line` | bool | Placed on the defence wall line, not the open surface (Bulwark). |
| `root_radius` / `brace_mult_normal` / `brace_mult_surge` / `brace_seconds` / `rest_seconds` | int / float | Bulwark auto-root radius and brace duty-cycle tuning. |

The `apothecary` entry adds a brewing schema the validator checks in detail:
`doses_by_fuel` (fuel id `->` doses, each must be `is_fuel`), `water_per_dose`,
`stored_water_floor`, `dose_target_default`/`dose_target_max`, and a three-entry
`dose_levels` array (`label`, `save_chance` in `(0,1]`, `ingredients`, and a
`cost` on tiers beyond the first).

The Black Hollow `hollow_crucible` is a class-agnostic single-recipe processor
(`is_processor: true`, `recipe_id: "cast_hollow_vault_block"`, no
`selectable_recipes`) — the smelter/fossil-grinder pattern.

```json
"drill": {
  "id": "drill", "family": "drill", "tier": 1, "display_name": "Drill",
  "work_rate": 5.0, "max_fuel": 500.0, "footprint_w": 1, "footprint_h": 1,
  "is_processor": false, "durability_min": 50, "durability_max": 50, "max_placed": 3
}
```

## layers.json

An object with a single `layers` array; `DataRegistry` stores the array. Each
entry is a depth band (underground is positive Y).

| Field | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `description` | String | Identity and UI. |
| `main_material` | String | Dominant material id. |
| `depth_start` / `depth_end` | int | Inclusive tile-depth range. |
| `blend_depth` | int | Transition blend into the layer above. |
| `color_hex` | String | Band color. |
| `ore_inclusions` | Array | `{material, chance}` scatter rolls within the band. |

```json
{
  "id": "stone", "display_name": "Stone", "main_material": "stone",
  "depth_start": 27, "depth_end": 51, "blend_depth": 3, "color_hex": "#8e8e8e",
  "ore_inclusions": [ {"material": "coal", "chance": 0.09} ]
}
```

## discoveries.json

Top level is `discovery_id -> Dictionary` — underground pockets found while
digging.

| Field | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `description` | String | Identity and UI. |
| `chance` | float | Base spawn chance. |
| `layers` | Array[String] | Layer ids the discovery can appear in. |
| `layer_chances` | Dictionary | Per-layer spawn chance override. |
| `reward_type` | String | `survival`, `inventory`, `maw_resonance`, `plague_spore`, `hazard`, and others. |
| `reward` | Dictionary | Reward payload, e.g. `{"food": [3, 8]}` (min/max range). |
| `layer_rewards` | Dictionary | Per-layer reward override (must be a spawn layer). |
| `variety_names` | Array[String] | Flavour names (e.g. mushroom varieties). |
| `color` | Array[float] | RGB hint color. |

```json
"water_pocket": {
  "id": "water_pocket", "display_name": "Water Pocket", "chance": 0.04,
  "layers": ["clay", "stone", "shale_coal", "iron_rock"],
  "reward_type": "survival", "reward": {"water": [5, 20]},
  "color": [0.40, 0.70, 0.95]
}
```

## black_hollow.json

Two top-level arrays: `expeditions` (the five Wren's Expeditions routes) and
`notes` (the 15-note chronicle). Loaded by `DataRegistry`
(`get_expeditions` / `get_expedition` / `get_chronicle_notes`); the validator
cross-checks `material_pool` / `exotic_pool` ids against `materials.json` and
`layer_id` against the layers. See [Black Hollow](/docs/underroot/black-hollow)
and [Black Hollow Expeditions](/docs/underroot/black-hollow-expeditions).

| Field (expedition) | Type | Meaning |
|---|---|---|
| `id` / `display_name` / `layer` | String | Identity and UI. |
| `layer_id` | String | Home layer that must be reached (by depth) to unlock the route. |
| `entry_cost` | int | Gold charged per attempt. |
| `start_depth` | int | Ladder rows before digging begins. |
| `days_away` | float | Village time a successful trip costs. |
| `cooldown_days` | float | Wren's rest after a successful bank (game-days). |
| `material_pool` | Array | `{id, w}` weighted common-material roll. |
| `exotic_pool` | Array[String] | Exotic ids that can appear (must exist in `materials.json`). |
| `bands` | Array | `{depth, mult}` — reached-depth gold multiplier curve (banked gold only). |
| `exotic_from` / `relic_from` / `schematic_from` / `resonant_from` / `vein_from` | int | Depth floors (tiles below the dig face) below which each special can appear. |

Each `notes` entry is `{id, act, title, voice, text}` — an ordered fragment of
Black Hollow's history, logged account-wide (`SaveManager.chronicle_notes`) when
its relic is banked. Special spawn *rates* and the schematic/resonant per-descent
caps are code constants in `BlackHollowOverlay`, not data — see
[Black Hollow Expeditions](/docs/underroot/black-hollow-expeditions).

```json
"expeditions": [
  {
    "id": "rusted_works", "display_name": "Rusted Works", "layer": "Iron-ore workings",
    "layer_id": "iron_rock", "entry_cost": 200, "start_depth": 6, "days_away": 0.6, "cooldown_days": 3,
    "material_pool": [{"id": "stone", "w": 4}, {"id": "iron_ore", "w": 3}],
    "exotic_pool": ["ancient_clay"],
    "bands": [{"depth": 0, "mult": 1.0}, {"depth": 18, "mult": 1.5}, {"depth": 40, "mult": 2.0}, {"depth": 66, "mult": 2.5}],
    "exotic_from": 8, "relic_from": 20, "schematic_from": 34, "resonant_from": 12, "vein_from": 20
  }
],
"notes": [
  {"id": "n01", "act": 1, "title": "Founding stone", "voice": "Alder", "text": "Broke ground where the old road ends. …"}
]
```

## projects.json

Top level is `project_id -> Dictionary` — villager tasks. Categories seen in
data: `supply_run`, `construction`, `craft_request`, `exploration`,
`emergency`, `luxury`, `epic`.

| Field | Type | Meaning |
|---|---|---|
| `category` | String | Task family (drives spawn logic and UI). |
| `title` / `flavour` | String | UI text. |
| `materials` | Dictionary | `material_id -> [min, max]` amount asked. |
| `reward_type` | String | `food`, `water`, `gold`, `safety_boost`, `population`, `none`. |
| `reward_scale` | Array[int] | `[min, max]` reward magnitude. |
| `reward_pool` | Array | Weighted alternative rewards (`{type, scale, weight}`). |
| `deny_penalty_type` | String | Penalty resource on refusal (`none` = no penalty). |
| `deny_penalty_pct` | Array[float] | `[min, max]` fraction penalty. |
| `min_depth` | int | Depth gate before the task can spawn. |
| `spawn_weight` | float | Relative spawn frequency. |

```json
"coal_supply": {
  "category": "supply_run", "title": "Coal for the hearths",
  "materials": { "coal": [4, 8] }, "reward_type": "food",
  "reward_scale": [14, 26],
  "reward_pool": [
    { "type": "food",  "scale": [14, 26], "weight": 2.0 },
    { "type": "gold",  "scale": [8, 14],  "weight": 1.0 }
  ],
  "deny_penalty_type": "food", "deny_penalty_pct": [0.12, 0.22], "min_depth": 0
}
```

## balance.json

An object of tuning sections, each a `key -> number` dictionary. Scripts read
via `balance.get(section).get(key)`, so a missing key silently falls back to a
hardcoded default — `tools/validate_data.gd` gates every key the code reads via
`REQUIRED_BALANCE_KEYS`. Sections and a sample of their keys:

| Section | Sample keys |
|---|---|
| `survival` | `food_drain_per_second`, `water_drain_per_second`, `work_mult`, `idle_mult`, `storage_cap`, `well_trickle_rate` |
| `village` | `base_population`, `food_per_person_per_day`, `shelter_target_days`, `wall_hp_per_person`, `artificer_population`, `building_milestones` |
| `maw` | `start_x`, `grace_period`, `base_chew_rate`, `pressure_growth_per_day`, `pressure_max`, `hunger_step_thresholds`, `repel_pushback`, `breach_stun` |
| `walls` | `max_height_units`, `mix_bonus_per_type`, `roots_required` |
| `dynamite` / `mining_charge` | `fuse_min`, `fuse_max`, blast geometry, `blast_food_dmg`, `blast_water_dmg` |
| `world` | `collapse_max_range`, `collapse_fill_near`, `collapse_fill_far` |
| `time` | `day_duration` |
| `crafting` | `speed_multiplier` |
| `trading` | `basic_efficiency`, `astrolabe_efficiency`, `astrolabe_auto_interval` |
| `astrolabe` | `chew_increase_base`, `chew_increase_volume`, `exotic_chance_per_use`, `exotic_chance_cap` |
| `skills` | `node_fractions`, `node_pct`, `champion_fill_mult`, `inherit_factor`, `anchors`, `champion_class`, `class_info` |

```json
"maw": {
  "start_x": 620.0, "grace_period": 360.0, "base_chew_rate": 1.0,
  "pressure_growth_per_day": 0.04, "pressure_max": 5.0,
  "hunger_step_thresholds": [1.25, 1.5, 2.0, 2.5, 3.0, 4.0, 4.5]
}
```

### The `skills` block

The skill/class tuning. `SkillManager` / `SkillMath` read it via
`DataRegistry.balance.get("skills", {})`.

| Key | Type | Meaning |
|---|---|---|
| `node_fractions` | Array[float] (7) | Cumulative XP threshold per node, as a fraction of a skill's node-5 `anchor` (`[0.02, 0.06, 0.15, 0.40, 1.00, 2.25, 4.50]`). |
| `node_pct` | Array[float] (7) | Cumulative % bonus per node (`[0.04, 0.10, 0.18, 0.30, 0.45, 0.60, 0.75]`). |
| `champion_fill_mult` | float | Champion effective-XP multiplier — half the raw cost (`2.0`). |
| `inherit_factor` | float | Fraction of the % bonus an heir keeps on lineage death (`0.5`). |
| `anchors` | Dictionary | Per-skill node-5 XP: `{digging 4000, foraging 500, crafting 800, building 20000, population 80, trading 60}`. |
| `champion_class` | Dictionary | `skill -> class` (`digging miner`, `foraging hunter`, `crafting smith`, `building mason`, `population chieftain`, `trading merchant`). |
| `class_info` | Dictionary | Per-class UI + handicap data (below). |

Each `class_info.<class>` entry: `name`, `skill`, `emblem`, `accent` (hex),
`landmark`, `landmark_hint`, `blurb`, `effect_label`, `capstone`,
`capstone_hint`, `train_hint`, and `handicap` = `{label, mults}`. `mults` is an
`area -> multiplier` map read by `SkillManager.class_penalty(area)`; the areas
in use are `tool_durability`, `dig_speed`, `craft_speed`, `harvest_yield`,
`trade`, `wager`, and `population` (direction baked into the value).

```json
"skills": {
  "node_fractions": [0.02, 0.06, 0.15, 0.40, 1.00, 2.25, 4.50],
  "node_pct": [0.04, 0.10, 0.18, 0.30, 0.45, 0.60, 0.75],
  "champion_fill_mult": 2.0, "inherit_factor": 0.5,
  "anchors": {"digging": 4000, "foraging": 500, "crafting": 800,
              "building": 20000, "population": 80, "trading": 60},
  "champion_class": {"digging": "miner", "foraging": "hunter", "crafting": "smith",
                     "building": "mason", "population": "chieftain", "trading": "merchant"},
  "class_info": {
    "miner": {"name": "Miner", "skill": "digging", "landmark": "The Deepdrill",
              "effect_label": "dig speed", "capstone": "Rich Veins",
              "handicap": {"label": "Rough on tools — they wear 15% faster.",
                           "mults": {"tool_durability": 1.15}}}
  }
}
```

See [Skill Tree and Classes](/docs/underroot/skill-tree-and-classes) for the
full class table and application sites.

## trades.json

The villager visit economy. Top level is a set of named arrays plus a `wager`
object.

| Key | Shape | Meaning |
|---|---|---|
| `material_food_trades` | Array | `{material, cost, food_per}` — units of material for food. |
| `gold_trades` | Array | `{gives_id, gives_amount, gold_cost, type}`; `type` is `supply` (to bars) or `item` (to inventory). Optional `challenge` gate. |
| `tool_offers` | Array | `{tool_id, display, ...}`. Priced either by material-cost keys (barter, e.g. `dirt`/`clay`) or by `gold_cost`. Optional `consumable` (bool — repeatable stackable buy → inventory, capped to `max_stack`) and `gate_recipe` (string — hide the row until that recipe is unlocked). |
| `cosmetic_offers` | Array | `{id, gold_cost}` cosmetics for sale. |
| `wager` | Dictionary | Scout's Wager: `cost`, odds, `exotic_pool`, `consolation_pool`. |

```json
"material_food_trades": [
  {"material": "dirt",  "cost": 3, "food_per": 1},
  {"material": "clay",  "cost": 1, "food_per": 1}
],
"gold_trades": [
  {"gives_id": "food", "gives_amount": 5, "gold_cost": 10, "type": "supply"}
],
"tool_offers": [
  {"tool_id": "pickaxe",  "display": "Pickaxe",  "dirt": 50, "clay": 25},
  {"tool_id": "iron_axe", "display": "Iron Axe", "gold_cost": 500, "gate_recipe": "iron_axe"},
  {"tool_id": "dynamite", "display": "Dynamite", "gold_cost": 250,
   "consumable": true, "gate_recipe": "dynamite"}
]
```

## buildings.json

Bell-tower village buildings. Top level is `building_id -> Dictionary`.

| Field | Type | Meaning |
|---|---|---|
| `display` | String | UI label. |
| `metric` | String | `food`, `shelter`, or `safety` (validator-enforced). |
| `champion` / `pitch` | String | Elder name and flavour. |
| `costs` | Array[Dictionary] | Per-level material costs (`costs[i]` = level `i+1`). |
| `bonus` | Array[int] | Flat support added to `metric` at each level. |

```json
"farm": {
  "display": "Farm", "metric": "food", "champion": "Elder Maren",
  "costs": [
    {"wood": 20, "stone": 10},
    {"wood": 10, "iron": 5, "gold": 30}
  ],
  "bonus": [30, 65, 110]
}
```

## cosmetics.json

Three top-level keys: `slot_order` (Array of slot names), `items`
(`item_id -> Dictionary`), and `dyes` (`dye_id -> Dictionary`).

| Field (item) | Type | Meaning |
|---|---|---|
| `slot` | String | Slot the item fills (`form`, `skin`, `headwear`, `hair`, `beard`, `tunic`, `boots`, `extra`, `class`). |
| `name` | String | UI label. |
| `kind` | String | `color`, `style`, or `style_color`. |
| `source` | String | `free`, `milestone`, `discovery`, `scout`, `wager`, `secret`. |
| `default` | bool | The humble starting variant for its slot. |
| `color` | String | Hex color (color/style_color kinds and dyes). |
| `trigger` | String | Milestone id that unlocks it (e.g. `"iron_pickaxe"`, `"mastery:black_rot"`, `"champion:miner"`). |
| `hidden_until_owned` | bool | Hidden from the customizer until owned. |
| `effect` | Dictionary | Gameplay multipliers, e.g. `{"dig_speed_mult": 1.4}`. |

```json
"boots_furcuff": {
  "slot": "boots", "name": "Fur-cuffed Boots", "kind": "style",
  "source": "discovery", "effect": {"dig_speed_mult": 1.1}
}
```

### The `class` cosmetic slot

`slot_order` ends with a dedicated `class` slot (additive to `extra`), the home
of the class signature accessories. Two families:

- **Signature accessories** — one per class, `source: free` (always equippable),
  auto-seeded for the calling on new game: `class_ropecoil` (Miner),
  `class_hunterkit` (Hunter), `class_apron` (Smith), `class_toolbelt` (Mason),
  `class_pauldrons` (Chieftain), `class_moneybag` (Merchant). `class_none` is the
  `default: true` variant.
- **Prestige variants** — `class_*_prestige`, `source: milestone`,
  `trigger: "champion:<class>"`, `hidden_until_owned: true`. Granted
  account-wide by `CosmeticManager` when the class's champion reaches node 7.

```json
"class_ropecoil": {"slot": "class", "name": "Miner's Rope", "kind": "style", "source": "free"},
"class_ropecoil_prestige": {"slot": "class", "name": "Miner's Gilded Line", "kind": "style",
  "source": "milestone", "trigger": "champion:miner", "hidden_until_owned": true}
```

`dyes` are simpler `{name, color, source, default}` entries for the tunic dye
picker, read via `DataRegistry.get_cosmetic_dye()`.

> The equipped loadout (including the `class` slot) is per-run — it rides
> `current_run.cosmetics_loadout` in the save (`SAVE_VERSION = 5`), alongside
> `current_run.skills` and `current_run.class`. `cosmetics_owned` and the new-run
> loadout template live account-wide in `config.json`. See
> [Save System and Migration](/docs/underroot/save-system-and-migration).

## codes.json

Redeemable codes, keyed by the **lowercase SHA-256 hex digest** of the uppercase
code string so plaintext never ships in the pck. The validator rejects any key
that is not a 64-char hex digest. Player input is normalized and hashed by
`DataRegistry.get_code()`.

| Field | Type | Meaning |
|---|---|---|
| `repeatable` | bool | `false` = one redemption per account (tracked in `config.json`). |
| `rewards.tools` | Dictionary | `tool_id -> count` (routed to `ToolState.add_tool`). |
| `rewards.materials` | Dictionary | `material_id -> amount` (routed to `Inventory.add`). |
| `rewards.cosmetics` | Array[String] | Cosmetic ids (routed to `CosmeticManager.unlock`). |

```json
"1ac05da52cdab0440527ec1cd116f8e277b5feebc5c79f4fd1fa7b53e9ccd06a": {
  "repeatable": false,
  "rewards": {
    "tools": { "quartzite_pickaxe": 1 },
    "cosmetics": ["form_axel"]
  }
}
```

## Key files

- `scripts/resources/DataRegistry.gd` — loads and serves every file above.
- `tools/validate_data.gd` — referential validator (`DATA OK` / `DATA FAIL`).
- `scripts/resources/MaterialDef.gd`, `ToolDef.gd`, `RecipeDef.gd` — typed wrappers.

## Related

- [DataRegistry and the Data Files](/docs/underroot/dataregistry-and-the-data-files)
- [Adding Materials Tools and Recipes](/docs/underroot/adding-materials-tools-and-recipes)
- [Adding a Machine](/docs/underroot/adding-a-machine)
- [Adding a Redemption Code](/docs/underroot/adding-a-redemption-code)
- [Black Hollow](/docs/underroot/black-hollow)
- [Skill Tree and Classes](/docs/underroot/skill-tree-and-classes)
- [Verification and CI](/docs/underroot/verification-and-ci)