Skip to content

Latest commit

 

History

History
610 lines (497 loc) · 27.5 KB

File metadata and controls

610 lines (497 loc) · 27.5 KB

Content Authoring — The Master's Shadow

"There is no page in my grimoire I did not write."

This guide is for authors who wish to add or reshape the game's content — the bloodlines, spells, wild events, endings, and the codex of the wilds — without touching a line of C++, and for those who wish to regenerate its pixel art and voice. Everything the game reads from disk lives under data/ and assets/, and every record obeys one small, strict grammar.

Read this whole page before you edit. The parser is deliberately unforgiving in a few places (a mistyped separator silently swallows a record), and a handful of files are codices that describe the engine rather than drive it — editing them changes the lore the player reads, not the numbers the engine runs.


1. The data/ layout

ContentDB loads content by walking the data/ tree recursively at launch (ctx_.content.load_dir(resolve_resource_dir("data")) in src/game/app.cpp). Every .md or .txt file found is parsed; other extensions are ignored. Load order is sorted by path for determinism, but nothing hashable depends on it.

Directory File type: Count Role
data/lineages/ lineages.md lineage 6 Bloodline traits — drives the engine (injected)
data/enemies/ enemies.md enemy 8 Enemy stats + names — drives the engine (injected)
data/chaos/ chaos.md chaos 9 Apex chaos base weights + flavour — drives the engine (injected)
data/tuning/ tuning.md tuning 1 Global tunable knobs — drives the engine (injected)
data/world/ terrain.md terrain 9 Terrain move-cost + encounter base — drives the engine (injected)
data/events/ events.md event 36 Phase 2 wild events — drives the engine (journaled RNG)
data/recipes/ recipes.md recipe 3 Phase 2 brew recipes — cost + effect deltas (app-layer)
data/spells/ spells.md spell 22 Spell catalogue — drives the engine (injected; combat + field casting)
data/items/ items.md item 13 Item catalogue — drives the engine (injected; inventory + use)
data/difficulty/ difficulty.md difficulty 2 Normal/Hardcore rows — drives the engine (injected; row 0 must mirror the literals)
data/reagents/ reagents.md reagent/synergy 34 Tag-synergy alchemy — drives the engine (injected; BrewCombine)
data/traps/ traps.md trap 7 The Architect's traps — drives the engine (injected)
data/master_spells/ master_spells.md master_spell 6 The Master's counter-workings — drives the engine (injected)
data/reputation/ reputation.md repevent 12 Authored faction events — drives the engine (injected)
data/lore/ lore.md lore 32 Codex fragments, unlock-gated — presentation
data/strings/ *.md uistring 500+ UI + narrative text read via tr()presentation
data/barks/ *.md bark/apexbark 35 Master Malacor taunt corpora
data/camp/ camp.md dream 8 Night-camp dream vignettes
data/endings/ endings.md ending/coda 14 Ending titles + epilogues + per-lineage codas

Every bundle also carries an index.md (type: index) and log.md (type: log) — OKF progressive-disclosure + provenance records the runtime never queries. Regenerate both with python tools/gen_okf_index.py after adding records, and run python tools/okf_validate.py (gate G-OKF-CONFORM) before committing.

Codex vs. driver. Two ways data reaches the engine:

  • Injected rule tables (lineage, enemy, chaos, tuning, terrain): parsed in the app/headless layer into frozen POD structs (src/core/game_tables.hpp) and handed to ms_core once before the first turn via set_game_tables(). ms_core itself does no file I/O. These numeric fields must be integers — floats are stored as per-mille ints (1.251250), because std::stod is not byte-stable and a float leak would break the determinism hash. Rebalancing them changes the simulation, yet the hash is proven unchanged when values match (baseline 16425e914e1636ca).
  • Injected catalogues (spell, item, reagent/synergy, trap, master_spell, repevent, difficulty): also frozen into GameTables and drive the engine (CastSpell / UseItem / BrewCombine / traps / Master counters). Display fields (name, effect, description) are presentation; mechanical fields are integer-only and core-consumed.
  • App-layer / presentation reads (event, recipe, uistring, bark, dream, ending, lore, achievement): read by name from ContentDB in the game/UI layer. event applies resource swings through the journaled RNG; uistring is read via tr(id, fallback) (see §6).

Two save formats. Do not confuse them:

Format Where What it stores Used for
MSSAVE src/save/save.cpp seed + lineage + action journal Core replay / tools
MSSNAP src/game/run_save.cpp full GameState snapshot (flags, resources, meters) Continue on Normal; Hardcore never writes

Full player runs still hybrid-mutate outside the journal (see docs/ARCHITECTURE.md §1.2 and docs/MUTABLE_STATE_INVENTORY.md), so Continue must snapshot state rather than replay MSSAVE alone.

You may split a record type across multiple files inside its directory, or add new files — load_dir concatenates everything it finds. A single file may also hold records of mixed type:; the loader keys only on the type: field, not the filename.


2. The record grammar (read this twice)

Every content file is a flat sequence of records. The grammar is implemented in src/core/content_db.cpp and is intentionally tiny:

  1. Every meaningful line is key: value. The parser splits on the first colon. Everything before it (trimmed) is the key; everything after it (trimmed) is the value. A line with no colon is silently skipped — so free prose that happens to contain no : vanishes without warning.
  2. Records are separated by a line of exactly ---. The line must trim to the three characters --- and nothing else. ----, - - -, or --- with trailing junk after trimming will not split records. A leading --- before the first record is fine (it flushes an empty record, which is discarded).
  3. # starts a comment. Any line whose first non-whitespace character is # is ignored. There are no inline commentsfood: +6 # cache would make the value the literal string +6 # cache and break stoi. Put comments on their own line.
  4. Blank lines are ignored and may appear freely inside or between records.
  5. Leading/trailing whitespace is trimmed from both key and value on every line (spaces, tabs, \r, \n). Windows CRLF endings are safe.
  6. Duplicate keys: the last one wins (later cur.fields[key] = val overwrites earlier).

Required fields

Every record must carry:

  • id: — a unique, lowercase-with-hyphens slug. This is the index key (ContentDB::by_id). A record with no id: is still loaded and still counts in by_type, but it can never be looked up by name, and a duplicate id: will overwrite the earlier record's index entry (last wins).
  • type: — one of the seven types in the table above. by_type filters on this exact string. A typo here (type: evnt) makes the record invisible to the system that wanted it.

Value typing

Fields are stringly-typed on disk and coerced on read. Author your values to match how the field is consumed:

Accessor Coercion Author as Bad input falls back to
get(key) raw string any text the default (usually "")
geti(key) std::stoi 6, +6, -3 the default (0) — no throw
getf(key) std::stod 1.15, 0.20, -4 the default
getb(key) lowercased true / yes / 1 false (anything else)
getlist(key) comma-split a, b, c (optional [ ]) empty list

Numeric gotcha — no units, ever. geti/getf run stoi/stod, which parse the leading numeric prefix. +6 and -3 are fine (sign is part of the number). But 6 food, 6%, or 6 (per turn) parse as 6 only if the number leads — stoi("food 6") throws and you silently get the default. Never append units. Write +5 / -3 as bare signed integers, and floats as 1.15, not 1.15x.

Lists are one comma-separated value on a single line. getlist splits on commas, trims each item, drops empties, and strips one optional pair of [ ] brackets. There is no multi-line list syntax — everything must be on the one key: line.

A minimal skeleton

---
id: my-record
type: event
text: One line of prose the player will read.
food: +5

3. Worked example — adding a wild EVENT

Phase 2 (The Forest Pursuit) rolls non-combat wild events when you move onto a tile without an ambush. The dispatcher is Phase2Screen::roll_map_event() in src/game/phase2.cpp. It draws a random type: event record from the ContentDB and applies its effect fields directly to the player. This is the single most mod-friendly system in the game: add a record, get a new event, no rebuild of logic required.

The effect fields roll_map_event actually applies

Only these six numeric fields are read. Anything else on an event record is ignored by the engine (though text: is required — it is the line logged to the player). Values are bare signed integers (stoi, no units):

Field Applies to Notes
food: player.res.food added directly, unclamped
water: player.res.water added directly, unclamped
energy: player.res.energy added, then clamped to 0..60
mana: player.res.mana added, then floored at 0
trace: Sympathy Trace routed through adjust_trace (the Master's tracking vector); a positive value makes you easier to hunt
ingredient: alchemy reagents grants this many crafting ingredients (ActionType::GrantItem, s = "ingredient"); use +1 — the corpus caps ingredient gifts at 1

text: is required and is the only string field the engine surfaces. A missing text: yields a silent event.

Design conventions the existing corpus follows (from data/events/events.md): resource swings of 3–8, trace of 3–6, ingredient: +1 at most. Roughly half the events are pure boons, a quarter are mixed (a gain paid for with a loss), and a quarter are ominous (trace up, energy down). A positive trace: is what makes an event feel watched — the particle burst even turns violet when trace > 0.

Template

---
id: my-new-event
type: event
text: A single evocative line describing what the traveller finds.
food: +5
water: +3

A concrete new event

Say we want a "poisoned mushroom feast" — filling, but it draws the Master's notice and saps a little vigour. Drop this into data/events/events.md (or a new file like data/events/my_events.md):

---
id: fools-banquet
type: event
text: A spread of fat pale mushrooms grows in a fairy ring, and hunger makes the risk feel small.
food: +7
mana: +3
trace: +5
energy: -4

On load it becomes one more entry in the by_type("event") pool. When roll_map_event fires (a ~9% chance per non-ambush move) and rolls this record, the player gains 7 food and 3 mana, loses 4 energy (clamped into 0..60), and takes +5 Trace via adjust_trace. No recompile of game logic is needed — the event corpus is pure data.

Determinism note. roll_map_event draws from the engine's journaled RNG stream (eng()->rng()), so a given (seed, lineage, action-sequence) replays identically. Adding, removing, or reordering event records changes which record a given roll selects, so it will change replay outcomes and the state_hash. That is expected — new content is a new world. (The faction-war side-stream war_rng_ is independent and unaffected.)


4. Editing lineage records (the other real driver)

data/lineages/lineages.md holds the six bloodlines, and unlike the codices its numbers are consumed as gameplay traits (via magic/lineage). This is where you rebalance a bloodline's identity. The fields, drawn from the shipped records:

Field Type Meaning
name:, title:, desc: string Display identity
affinity: White/Grey/Black Primary magic colour
phase2_mult: float Phase 2 casting multiplier
phase2_color: string Colour that multiplier applies to
parasite_dur_mult: float Trace Parasite duration scaling
parasite_refresh_bonus: float Extra parasite refresh on Black cast
trace_gen_mult: float How much trace this bloodline sheds
clock_black_penalty: float Ritual-clock cost of Black work
echo_risk: bool Subject to Echo Bleed
echo_severity: / echo_ability: string / bool Echo tuning (Dravenblood high; Auralis' pattern-copy)
inversion: bool Can invert the ritual (Dravenblood)
domination_resist: bool Resists domination (Sylvara)
flow_resist: float Resistance to flow effects
bond_start: int Starting Sympathetic Bond (Veyra 30, all others 15)
p1_observation / p1_experimentation / p1_stealth / p1_puzzle int Phase 1 skill modifiers (+1/-1)
p1_suspicion_on_fail: int Extra Phase 1 suspicion on a failed action (Dravenblood)
p2_foresightp2_pattern_copy bool/int Phase 2 signature abilities (see below)
p3_hijack_bond, p3_inversion_bonus, p3_chaos_calm, p3_domination_resist, p3_echo_storm_ride int/bool Phase 3 apex traits
combat_white / combat_black int Combat affinity bonuses
flee_bonus / forage_bonus int Phase 2 utility bonuses

The Phase 2 signature abilities are what give each bloodline its verb:

  • Veyrap2_foresight: true (echo-foresight, p2_foresight_echo_cost: 1) and p3_hijack_bond: 40 (Phase-3 bond hijack). Pays for it with the highest echo bleed, trace_gen_mult: 1.20, and a hungry parasite.
  • Nyxarip2_decoy_strength: 3 / p2_decoy_cooldown: 4 power the false-trail decoy [D] that poisons the Master's scent map; p3_chaos_calm.
  • Thalorp2_ley_footing: true (footing on Water/Path); combat_white: 3.
  • Dravenbloodp2_black_siphon: 0.5 (Black damage → mana), inversion: true, p3_inversion_bonus: 4; parasite_dur_mult: 1.40 burns them fastest.
  • Sylvarap2_wild_forage: true, p2_beast_pact: 30, domination_resist.
  • Auralisp2_pattern_copy: true (copy the Master's last strategy [R]), p3_echo_storm_ride.

Editing safely. Tuning a numeric field (say bumping Nyxari's p2_decoy_strength from 3 to 4) is a data-only rebalance — save and relaunch. Adding a new bloodline requires code, because the lineage roster, the ctx_->lineage enum, the ability dispatch (lay_false_trail, auralis_invoke, p2_foresight handling, etc.), and the art/aura mapping in tools/msart.py (LINEAGE_COLOR) are all keyed in C++/Python by the six known ids. Data alone cannot register a seventh. Keep edits to the existing six ids (veyra, nyxari, thalor, dravenblood, sylvara, auralis).

Lineage template (fields you omit fall back to the accessor default):

---
type: lineage
id: veyra
name: Veyra
title: The Vessels
affinity: Grey
phase2_mult: 1.15
phase2_color: Grey
trace_gen_mult: 1.20
echo_risk: true
bond_start: 30
p1_observation: 1
p1_stealth: -1
p2_foresight: true
p3_hijack_bond: 40
combat_black: 1
desc: One line of bloodline flavour.

5. The codices — data that describes the engine

These four record types are looked up by id for their words, but their numbers and behaviour live in C++. Editing prose is safe and encouraged; editing a "stat" field changes what the codex says, not what the engine does.

Enemies (data/enemies/enemies.md) — mirrors src/world/bestiary.cpp

The bestiary records carry hp, atk, def, speed, affinity, caster, and a codex: blurb — but every one of those stats is defined in code in generate_enemy() and pick_kind() (src/world/bestiary.cpp). The eight enemy kinds, their base HP/attack/defence/speed, biome weighting, tier scaling, loot, and sprites are all hardcoded there. The data file exists so the numbers can be displayed and lore-checked in one place, and the tests assert the data and code agree (tests/test_content.cpp expects by_type("enemy").size() == 8).

So: to rebalance an enemy or add a ninth, you edit bestiary.cpp (the EnemyKind enum, the pick_kind weight table, the per-kind stat block in generate_enemy) — and then update enemies.md to match, so the codex stays honest. Editing only the data file changes nothing the player fights. Keep the two in lockstep; the fields to mirror are the base (un-tiered) values:

---
type: enemy
id: dire-wolf
name: Dire Wolf
biome: DeepWoods
hp: 14
atk: 7
def: 1
speed: 8
affinity: Grey
caster: false
codex: A single line of bestiary lore for the player to read.

Spells (data/spells/spells.md)

Fifteen spells across White / Grey / Black, each with name, color, tier, mana, skill, effect, and description. The effect: string (e.g. deal 7-12 fire damage) is descriptive text, not a parsed formula — the actual damage rolls and mechanics are resolved in code. Edit mana:/tier: freely for display, edit description: for flavour, but the mechanical resolution of a spell is not driven by these fields. Adding a genuinely new spell effect needs code.

---
id: ward
type: spell
name: Ward
color: White
tier: 1
mana: 1
skill: Willpower
effect: shield 8 for one turn
description: A ring of pale sigils flares between you and the dark.

Chaos (data/chaos/chaos.md), Endings (data/endings/endings.md), Items (data/items/items.md)

  • Chaos — nine Phase-3 apex events, mirroring the Chaos enum in src/magic/apex.hpp. Each is id + name + one-line effect: prose. Which chaos fires and what it does numerically is code; the record supplies the name and the line the player reads.
  • Endings — eight records mirroring the Ending enum, each with title, tone (good/dark/bittersweet), and epilogue. The engine decides which ending you earn; the record supplies its title and closing paragraph.
  • Items — sixteen records with name, category, phase (1/2/3), and description. Presentation and lore; behaviour is elsewhere.

Templates:

---
id: resonance-surge
type: chaos
name: Resonance Surge
effect: One line describing what the chaos does to the clash.
---
id: true-freedom
type: ending
title: TRUE FREEDOM
tone: good
epilogue: The closing paragraph the player reads when this ending fires.
---
id: masking-draught
type: item
name: Masking Draught
category: consumable
phase: 2
description: One line of item flavour.

6. Editing on-screen text — the tr() strings

Almost every word the player reads — screen titles, buttons, key-hint bars, dialogue, combat and camp log lines — now lives in data/strings/*.md as type: uistring records, one per line of text:

---
type: uistring
id: combat.flee_success
text: You break away into the trees!

The screens fetch them by id through tr("combat.flee_success", "…fallback…") (src/core/strings.hpp). The second argument is the fallback — the original English literal, baked into the binary — so if you delete a record or mistype its id, the game still shows the fallback rather than blank text. To re-skin or translate the game, edit the text: fields (or add a whole parallel bundle); leave the ids alone, since the code looks them up by id.

Three parser rules you cannot break (from §2, but they bite hardest here):

  1. One physical line per value. The loader drops any wrapped continuation line, so a text: that spills onto a second line is silently truncated. Keep each string on a single (arbitrarily long) line.
  2. Values are trimmed — leading/trailing spaces are stripped. Never rely on a space at the very start or end of a value.
  3. Quotes are literal — the loader does not strip surrounding quotes, so text: "hi" shows the quotation marks. Only include quotes you actually want on screen.

Because of rules 2–3, runtime-interpolated lines are stored as whole printf templates, not glued fragments. A line built in code as TextFormat(tr("combat.you_strike_fmt", "You strike for %d.").c_str(), dmg) has a matching record:

---
type: uistring
id: combat.you_strike_fmt
text: You strike for %d.

Keep the %d / %s specifiers exactly (same count and order as the code passes arguments), or the text will be malformed. %d is a number, %s a name/word.

After editing strings, run python tools/okf_validate.py — it flags dropped lines, duplicate ids, and typeless records.


7. Regenerating pixel art

All sprites, tiles, props, UI, particles, and item icons are generated procedurally in Python into assets/. Nothing is hand-drawn; regenerating is deterministic (seeded), so a rerun reproduces the same pixels.

The msart module (tools/msart.py)

Every generator imports from the shared msart module, which provides:

  • PAL — the canonical palette, a dict of named colours grouped into ramps: warm near-black inks (ink0ink4), wood/leather (wood0wood5), gold/brass (gold0goldglow), parchment cream (cream0cream3), blood-red (red0red4), nature-green (grn0grn3), grey-magic terracotta (gry0gry3), black-magic void purple (vio0vio4), white-magic amber (wht0wht3), water blue (blu0blu3), skin, plus pure black/white. Always draw from PAL so new art stays inside the game's gothic identity.
  • MAGIC_RAMP — White/Grey/Black shading ramps for auras and particles.
  • LINEAGE_COLOR — per-bloodline aura tint (e.g. veyra: vio3, sylvara: grn2). Keyed by the six lineage ids.
  • C(name, a=255) — palette colour → RGBA tuple with alpha.
  • lerp(c0, c1, t) — linear colour blend.
  • Canvas — a small RGBA pixel canvas with helpers: set, rect, hline, vline, vgrad (vertical gradient), disc, ring, dither (4×4 Bayer), outline (1px outline around opaque pixels), shadow_ellipse, and save (which makes parent dirs and writes the PNG).
  • sheet(cells, cell_w, cell_h, cols) — compose a list of Canvas cells into a single contact spritesheet.

Art is drawn at native resolution (no upscaling); raylib scales with a POINT filter for crisp pixels.

The generators

Each tools/gen_*.py builds one asset family and writes into assets/ (sprites, tiles, props, UI, particles, items):

tools/gen_characters.py    tools/gen_enemies.py    tools/gen_tiles.py
tools/gen_props.py         tools/gen_items.py      tools/gen_ui.py
tools/gen_particles.py

Run one from the tools/ directory (they import msart as a sibling and resolve assets/ relative to themselves):

cd C:\Projects\the_masters_shadow-main\tools
python gen_enemies.py     # regenerate the seven enemy sprites + contact sheet

gen_enemies.py, for instance, defines one function per creature (wolf(), spore(), wraith(), mire(), leywisp(), bandit(), straggler()), each building a 40×40 Canvas from PAL colours, calling cv.outline(...), and saving to assets/sprites/enemy_*.png; gen() also composes a _contact_enemies.png sheet. To add or reshape a sprite, add/edit a draw function and add it to that file's gen() cell list. Requires Pillow (PIL).

Note: the enemy sprites and the enemy stats are separate concerns. New art in gen_enemies.py does not add an enemy to combat — that still needs the EnemyKind wiring in src/world/bestiary.cpp (and a matching enemies.md codex entry, and a sprite path pointing at your new PNG).


8. Regenerating audio (ElevenLabs)

Voice, SFX, music, and ambience are generated by tools/gen_audio_elevenlabs.py through the official ElevenLabs SDK into assets/audio/. The shipped game bundles the generated audio and needs no key at runtime — you only need a key to regenerate.

Key handling

The key is read, in order, from:

  1. the ELEVENLABS_API_KEY environment variable, or
  2. the first non-blank line of elevenlabs-api-key.txt at the repo root.

If neither yields a key the script exits with No ElevenLabs API key found. The key is never printed and never committed — keep elevenlabs-api-key.txt out of version control.

$env:ELEVENLABS_API_KEY = "sk-..."      # session-only; do not commit
cd C:\Projects\the_masters_shadow-main\tools
python gen_audio_elevenlabs.py          # generate anything missing/changed

The manifest and hash caching

MANIFEST in the script lists every audio asset as a dict with a path, a kind (tts | sfx | music), and the generation inputs (text, plus voice for TTS, dur for SFX, ms for music). TTS lines are cast to voice roles (master, narrator, lady, fairy, rebel, goblin) which pick_voices() scores against the account's available voices.

Output is cached by content hash. For each asset the script computes a SHA-1 over kind + voice + text + dur + ms, writes it to tools/.audio_cache/<name>.sig, and on the next run skips any asset whose existing .sig still matches. So re-runs only regenerate what actually changed — edit a line's text and only that clip is remade.

Flags:

Command Effect
python gen_audio_elevenlabs.py Generate anything missing or changed
python gen_audio_elevenlabs.py --force Regenerate everything (ignores the cache)
python gen_audio_elevenlabs.py --only voice Only assets whose path contains voice
python gen_audio_elevenlabs.py --list Print the manifest, make no API calls

To add a line, append a dict to MANIFEST (pick an existing voice role for TTS), then run without --force — the hash cache leaves every unchanged clip untouched and generates only your new one. Requires the elevenlabs Python SDK.


9. Verifying your content

After editing, sanity-check that the loader still sees the counts it expects. The content tests live in tests/test_content.cpp and assert, among other things, by_type("lineage") == 6, by_type("enemy") == 8, and by_type("event") >= 20. Build and run the test target:

cmake --build build --target ms_tests
build\ms_tests            # 46 doctest tests, including the content-load checks

Common failures and their causes:

  • A record vanished — check that its separator is a lone --- (trimmed), and that it has both id: and type:.
  • A number reads as 0 / a default — you appended a unit or comment to the value. Strip it; use a bare signed integer like +5 or -3.
  • A new event never fires — confirm type: event (not evnt) and that text: is present; the roll is only ~9% per non-ambush move, so test across several days.
  • Replays diverge / state_hash changed — expected whenever you add, remove, or reorder event records, since that shifts which record a given RNG roll selects.

"Keep to your chores, apprentice." Author well, and the wood remembers.