DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0006: Game Data Loading and Fail-Loud Validation — StreamingAssets JSON to Driftlands.Data, configVersion, TideConfig Arrival, the Validation Gate

Status

Accepted

Accepted 2026-08-28 — architecture-review-2026-08-28.md (verdict CONCERNS; review amendments applied before acceptance).

Date

2026-08-28

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3) · C#
DomainCore / Data
Knowledge RiskLOW — Application.streamingAssetsPath, File.ReadAllText, JSON parsing (Newtonsoft per ADR-0004), and boot-order scripting are pre-cutoff and unchanged. No post-cutoff API.
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md, deprecated-apis.md, current-best-practices.md; ADR-0001 (TideConfig, TideConfigValidator, AdvanceEpoch), ADR-0002 (SoftCloseTick needs W), ADR-0003 (Driftlands.Data, IGameData, Schema_* tests), ADR-0004 (configVersion gate; tide block; json_library stance); every GDD's "Config validation (load-time, fail loudly)" table and the cross-system CI gates.
Post-Cutoff APIs UsedNone.
Verification Required(1) ~~streamingAssetsPath a plain directory on desktop~~ confirmed (Unity manual: standalone reads with System.IO; only WebGL/Android need UnityWebRequest); (2) [RuntimeInitializeOnLoadMethod(SubsystemRegistration)] runs before any scene Awake — confirmed by the 6.3 ScriptReference; that it precedes a scene-placed NetworkManager's own initialisation is a project ordering fact to test, not a Unity guarantee; (3) Newtonsoft JObject.Parse + explicit reads on ~12 files totals < 100 ms on the host machine; (4) the fatal-config screen renders with no game data loaded and no USS (6.3 strict USS parsing must not be able to break the screen that reports breakage); (5) File.ReadAllText handles a UTF-8 BOM (it does — explicit Encoding.UTF8 detection) and CRLF/LF identically (JSON is whitespace-insensitive); (6) a Steam-under-Program Files install where StreamingAssets is read-only still boots (nothing writes there — overrides live in persistentDataPath).

ADR Dependencies

Field Value
Depends OnADR-0001 (TideConfig + TideConfigValidator are defined there and called at stage 1; AdvanceEpoch is the writer this ADR's loader invokes — TD-ADR C9), ADR-0003 (Driftlands.Data assembly; IGameData shape; validation rules are pure logic). Amends (not a dependency): ADR-0004 — §1 tide block gains socketCount, §5 step 3 becomes the epoch check (see §5); ADR-0004's own dependency on this ADR's configVersion stays weak. Relabelled 2026-08-28 (architecture review C3: the mutual Depends-On formed a cycle)
EnablesADR-0002 (SoftCloseTick = φ+W from validated config — it lists this ADR under its Depends On), ADR-0005 (land lifecycle: TemplateDef, socket layout, startingTemplates), ADR-0007 (input config file), ADR-0008 (configVersion in the late-join header), ADR-0009–0012 / ADR-0015 (every system's data file and validation rows), ADR-0013/0014 (tint/UI config)
BlocksThe boot-sequence story; every system's first data file; the three cross-system CI gates (they read live named constants from IGameData)
Ordering NoteArchitecture Required-ADR row 6 = ADR-0006 (numbered by row, as every prior ADR cites it; ADR-0005 stays reserved for Land Lifecycle — TD-ADR C1). Every GDD's validation table is mechanised here, not re-decided — a row's semantics belong to its GDD; this ADR fixes where data lives, how it arrives, the validation order, the failure behaviour, and the two version/epoch derivations. Amends ADR-0004 §1 (tide block gains socketCount).

Context

Problem Statement

Every GDD ends with a load-time validation table and says "fail loudly", and the architecture's Principle 5 makes that a rule — but no document says where the JSON lives, how it reaches an engine-free assembly, in what order tables validate when rows cross-reference each other (a recipe's itemId must exist; a stationTier must be in Crafting's enum; harvestModifier ≤ 2.0 is Harvesting's ceiling on Crafting's data), what "fail loudly" does to the boot, or how configVersion and the tide's config epoch are derived. Three Proposed ADRs already consume the answer.

Constraints

Requirements

Decision

**Alternative A: JSON under StreamingAssets/data/ read synchronously at boot by a Runtime GameDataLoader through explicit Newtonsoft reads into engine-free *Def records; Driftlands.Data.Validation runs the GDD tables in dependency order into a ValidationReport; errors halt boot on a fatal-config screen; manifest.json carries configVersion; the save's tide block records socketCount and an N change calls AdvanceEpoch before reconcile stage 4.**

1. Files

StreamingAssets/data/
  manifest.json      { "configVersion": 1, "files": ["tide.json", "combat.json", …] }   ← configVersion bumped BY HAND (see §5)
  tide.json          TideConfig (ADR-0001)                       inventory.json  bagSlots, storageSlots, equipmentSlots, transferBatchWindow
  combat.json        knobs + archetype bands + unarmed 14        movement.json   moveSpeed, accelTime, remoteInterpBuffer
  harvesting.json    yields, bands, nodesPerLand, harvestModifierWarnCeiling, phase knobs   camera.json  followSmoothTime, aimBiasFrac
  input.json         moveStickDeadzone, aimStickDeadzone, aimPointerMinDistance, triggerPressPoint (ADR-0007 amendment; NO aim-persistence key — schema-negative)
  crafting.json      items (MaterialDef/GearDef + hooks + overrides + sourceTemplates), recipes, station tiers, durability, stationRange
  structures.json    StructureDef table, placementReach, socketNoBuildRadius, perimeterSegments   save.json  saveDebounceMs, backupCopies, flushDeadlineMs
  enemy-ai.json      archetype params, guardDensityByBand, aggro/leash/separation, telegraph cap
  land-dock.json     PreloadLeadTime (cited), bagFillSecs, nodeVisitSecs
  ui.json            uiScaleSteps, hudOpacityRange, reasonMinDisplayMs, reasonSwitchFrames, glintBandPx, pipQuotientMax + hud.md stage-9 rows (ADR-0014 amendment 2026-09-01; settings.json is runtime-written and deliberately NOT in the manifest)
  templates/<id>.json  TemplateDef: node counts per band, spawn points, socket layout (position, depositPoint, noBuildRadius), traversalProfile, addressableKey, candidateCells per band (ADR-0005 amendment; authored order load-bearing)

Desktop only: Application.streamingAssetsPath is a real directory on Windows/macOS/Linux; File.ReadAllText is used directly (no UnityWebRequest). The GDDs' assets/data/.json paths are these files: the repo folder assets/data/ is the source*, and Assets/StreamingAssets/data/ is a build-time copy (an IPreprocessBuildWithReport step mirrors the folder; the EditMode CI test reads the source folder via Path.Combine(Application.dataPath, "..", "assets/data") [SUPERSEDED — see Amendment 2026-09-03: the project lives in UnityProject/, so the source folder is Path.Combine(Application.dataPath, "..", "..", "assets", "data")]).

Shipped data is read-only; tuning overrides live in persistentDataPath (ruled 2026-08-28 — specialist B1). On macOS StreamingAssets sits inside the signed .app bundle: editing a file there breaks the code signature and Gatekeeper; on Windows a Steam install under Program Files may be read-only. So the bundle is never edited. If persistentDataPath/Driftlands/data-overrides/<file>.json exists for a manifest-listed file, it replaces that whole file (no key-level merging — merging is where silent half-edits hide); every active override is logged loudly at boot and shown on the main menu's debug line; validation runs identically over the resulting set. A friend tunes by dropping a file there, and a bug report can include it. Overrides never change configVersion (the manifest is canonical) — an override that needs a version bump is a content update, not a tune.

2. Loading (Runtime) → records (Data)

GameDataLoader (Driftlands.Runtime, plain class, no MonoBehaviour) runs first in the boot sequence (architecture §3.4 step 1) from a [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] entry point — the earliest hook, before any scene object's Awake (so before a scene-placed NetworkManager can start), and the one Unity documents as re-firing every Play session under domain-reload-off, which is also where the previous session's GameData instance is discarded (no static state survives). Synchronously:

2a. Numeric width on config records (2026-09-07, review C7)

Every duration, distance, rate, fraction or modifier on a *Config record is double. float appears only where a Unity API demands it (shader/tint floats, ITideClock.TintFloats). Tide schedule math stays long/double per ADR-0001; ADR-0003 §2's "gameplay geometry is float" is about System.Numerics spatial values and grants no licence for float durations. Rationale (core-harvesting story-007): two configs feeding one state machine at different widths cost one ulp at exactly the boundary the machine is defined by — 0.10f widens to 0.100000001490116. HarvestConfig shipped as float before this rule existed and is retyped to double by a follow-up story, after which story-007's decimal-snap workaround is re-evaluated.

3. Validation — order and rows (Driftlands.Data.Validation, pure)

Validators run in dependency order; a stage runs only if every stage it references produced no errors (so a missing item does not cascade into a hundred "unregistered itemId" errors):

Stage Validator Rows (the owning GDD's table, cited — semantics unchanged)
0ManifestconfigVersion positive int; file list complete and exact
1TideADR-0001 TideConfigValidator: 0 < W < C, `NC, deckSize > 2M, frac sum ≤ 1.0 (locked 0.40), N=7 reject; warning N ≥ 2 ∧ W < C/N` (AC-42b)
2Itemscrafting F4 maxStack locked 20 for fiber/timber/sinew; gear maxStack = 1; sourceTemplates[] present and non-empty for every material (crafting Rule 3b — the material→source-template mapping lives on ItemDef, owner Crafting; resolves architecture QQ-08; its referential check against Templates runs at stage 7); hook values: HarvestModifier ≤ 2.0 (a named constant Harvesting owns — HARVEST_MODIFIER_HARD_CEILING — not a value read from harvesting.json, which is why this check needs no stage-3 data and sits at the authoring source per crafting AC-43 — TD-ADR C3), DamageModifier > 0, DamageReduction < 1.0 at tier and per-item override; override ≤ next tier (crafting F2 monotonicity)
3Harvestharvesting EC7–10b: yieldPerSwing > 0, swingsTotal ≥ 1, harvestModifier ∈ (0, 2.0] with warning above harvestModifierWarnCeiling, itemId registered, bands strictly monotonic, harvestWindup + harvestActive ≥ 0.18; AC-25 no home-island nodes (home content has zero node defs); band split preserved
4Combatcombat EC10–12 / AC-37–39: damageReduction < 1.0, hitContribution > 0, HP bands > 0, swingWindup + swingActive ≥ 0.18; unarmedBaseDamage ∈ [10,18] (safe range → warning); telegraph ≥ 400 ms — mechanises combat AC-20 / telegraphFloor (hard floor 400 = the Rule 11 contract; corrected 2026-08-28 by the architecture review — the earlier "no combat AC exists for it" claim was wrong); enemy-ai's per-archetype attack configs are checked against it at stage 8 — TD-ADR C5b
5Recipes / Craftingcrafting EC16: unregistered itemId, empty inputs / missing output, craftTimeSeconds ≤ 0 reject / > craftTimeWarnCeiling warning, duplicate recipeId, unknown station, output ∈ inputs, durabilityPool < 10, counts ≤ 0, stationRange ≤ 0; crafting AC-56 TTK band (2–4 placed hits per tier row + per-item override vs combat's archetype bands, needs stages 2 and 4) — a Crafting rule, so it lives in Crafting's stage, and it checks only tiers whose bands are authored (cloth at MVP; iron is OQ1-unresolved and must not halt boot) — TD-ADR C4
6StructuresBB EC17: unregistered itemId, footprint ≤ 0, duplicate id, unknown category, cost ≤ 0 / empty, station↔stationTier pairing, storage↔storageSlots pairing, stationTier ∈ Crafting's tier enum, socketNoBuildRadius > 0, placementReach > 0, stationRange equals Crafting's (confirm, not re-derive); StructureDef field allow-list (BB AC-2)
7Templatesevery socket's depositPoint within socketNoBuildRadius and on buildable-adjacent shore (BB AC-63b/63c); node counts per band match harvesting.nodesPerLand split; spawn points: deep > shore per template (enemy-ai Rule 9, warning); traversalProfile present; referential integrity of ItemDef.sourceTemplates[] — every id exists in the pool (error; moved here from stage 2 because Templates is known only now — TD-ADR C3, and it groups with:) sole-source warning (LDU Rule 4): a known recipe input whose every sourceTemplates entry is absent from the pool; manifest.startingTemplates ⊆ pool (LDU Rule 3 seeding); ADR-0005 rows (pure): addressableKey == "land/<id>"; candidateCells[band].Count ≥ nodeCounts[band] for every band. The Addressables catalog-presence check is not a stage — it is LandCatalogCheck.RunAsync in Runtime after Addressables.InitializeAsync, rendering into the same fatal-config screen (ADR-0005 §4, TD-ADR C2).
8EnemyAIguardDensityByBand monotonic shore ≤ mid ≤ deep — warning while enemy-ai Rule 9's numbers are marked provisional (no GDD ruling makes it a reject; promote when Enemy AI locks its table — TD-ADR C5a); per-archetype attack configs respect combat's telegraph floor (stage 4's rule — reject); maxUninterruptibleTelegraphsPerTarget == 1 at MVP (reject — enemy-ai Rule 6 says fixed, a rule not a knob)
9Movement / Camera / Input / Inventory / Save / Land / Uischema-negatives (no MoveSpeedModifier/stamina/encumbrance keys; no aim-persistence key in InputConfig — ADR-0007), Input (ADR-0007): moveStickDeadzone/aimStickDeadzone ∈ [0.05, 0.5] (warning outside), aimStickDeadzone ≥ moveStickDeadzone − 0.1, aimPointerMinDistance > 0, triggerPressPoint ∈ [0.2, 0.9]; bagSlots ∈ [8,16] etc. as warnings outside safe ranges, storageSlots ≥ bagSlots × maxStack / bagSlots… (each GDD's Tuning Knobs "safe range" → warning; each GDD's Edge Case reject → error); Ui (ADR-0014): uiScaleSteps ascending and starting at 1.0, hudOpacityRange within [0,1] with min ≤ max, reasonMinDisplayMs ≥ 0, reasonSwitchFrames ≥ 1, pipQuotientMax ≥ 1 — all ranges as warnings, never a boot failure (a bad UI tuning value must not stop the game booting)
10CrossSystemGateswarning-level at boot, assert-level in CI: harvesting AC-28 supply ratio ≥ 1.5; crafting AC-54 first-session ratio ∈ [0.06, 0.30]; BB AC-45 bootstrap ∈ [0.10, 0.35]; BB AC-46 perimeter sessions ∈ [2, 4] at both haul extremes; LDU stripTimeEstimate(2)/(5) readout (no threshold); all recomputed from the live records — never literals (BB AC-45b/46b mutation-sensitivity)

Each finding is ValidationFinding { Severity, Stage, File, Path, Rule, Message } — Rule cites the GDD row (e.g. combat.EC10, bb.AC-63b) so a failure message names its design source.

4. Failure behaviour

5. configVersion and the config epoch

Architecture Diagram

 Boot (architecture §3.4 step 1)
 ────────────────────────────────
 StreamingAssets/data/manifest.json ─► GameDataLoader (Runtime, sync)
   ├─ tide.json … templates/*.json ─► JObject.Parse ─► *Reader (Data, explicit, IDataReader seam) ─► *Def / *Config records
   ▼
 GameData : IGameData (immutable)
   ▼
 Validation.Run (Data, pure)  stages 0→10 in dependency order ─► ValidationReport
   ├─ errors   ─► FATAL CONFIG SCREEN (no game data, no NetworkManager) — halt
   └─ warnings ─► log loudly; continue
   ▼
 inject IGameData → TideClock(seed, Configs.Tide) · HostContext.Data · ReconcileContext.Data · every system
   ▼
 Save.Load: Restore(TideState) ─► Advance(loadUtc) ─► if Configs.Tide.N ≠ save.tide.socketCount → AdvanceEpoch(newConfig, clock.T) ─► reconcile 4..9

 CI: Validation.Run(assets/data) in EditMode — errors OR stage-10 gate breaches fail the build

Key Interfaces

namespace Driftlands.Data
{
    public interface IGameData {
        int ConfigVersion { get; }
        IReadOnlyDictionary<ItemId, ItemDef> Items { get; }            // ItemDef: Material | Gear (hooks, overrides, durabilityPool), SourceTemplates: TemplateId[]
        IReadOnlyDictionary<RecipeId, RecipeDef> Recipes { get; }
        IReadOnlyDictionary<StructureId, StructureDef> Structures { get; }
        IReadOnlyDictionary<EnemyId, EnemyDef> Enemies { get; }
        IReadOnlyDictionary<TemplateId, TemplateDef> Templates { get; } // node counts per band, spawn points, SocketLayout, TraversalProfile, AddressableKey, CandidateCells (ADR-0005) — NO SourceMaterials: the mapping has one hand-authored home, ItemDef.SourceTemplates; a reverse index is derived at load if any consumer needs it (TD-ADR C7)
        GameConfigs Configs { get; }                                    // Tide, Combat, Harvest, Craft, Build, Inventory, Movement, Camera, Save, EnemyAI, Land — one record each
    }
    public interface IDataReader { /* same shape as ISaveReader (ADR-0004): TryInt/TryStr/TryBool/TryFloat, Array, Object — plus Keys() so unknown keys can be rejected */ }
    public enum Severity : byte { Error, Warning }
    public readonly record struct ValidationFinding(Severity Severity, int Stage, string File, string Path, string Rule, string Message);
    public sealed record ValidationReport(IReadOnlyList<ValidationFinding> Findings) { public bool HasErrors => …; }
    public static class Validation { public static ValidationReport Run(IGameData data); }    // pure; stages 0–10; CI and boot call the same method
}
namespace Driftlands.Runtime.Data
{
    public sealed class GameDataLoader {
        public static LoadOutcome Load(string dataRoot);                // LoadOutcome: Ok(IGameData, ValidationReport) | Fatal(ValidationReport)
        public static void ApplyConfigEpoch(TideClock clock, IGameData data, SavedTideBlock saved);   // the ONLY AdvanceEpoch caller; runs AFTER clock.Advance, uses clock.T as activationT
    }
}

Alternatives Considered

Alternative B: ScriptableObject assets per table

Alternative C: Addressables core-group TextAssets

Alternative D: Resources/ TextAssets

Alternative E (versioning): content hash as configVersion

Alternative F (epoch detection): treat any configVersion bump as an epoch

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
tide-schedulerEC15–18 / AC-36–43 (the degenerate-config validation table)stage 1 via ADR-0001's validator
tide-schedulerEC13 pool change at round boundary; EC14 N change = hard epoch (the config-change rulings)configVersion not bumped for tuning; socketCount comparison after Advance → AdvanceEpoch
combatEC10–12 / AC-37–39; Rule 5 hook values owned by Crafting but validated on load; unarmed 14 safe rangestage 4 (+ stage 2 for hook ceilings). Plus one new rule this ADR introduces: telegraph ≥ 400 ms as a load check (Rule 11 contract; no combat AC existed)
harvestingEC7–10b / AC-32–36; AC-25 no home-island nodes; AC-28 supply ratio (CI)stages 3 and 10
craftingEC16 (11 rows) / AC-40–47c; F2 per-item override monotonicity; AC-56 TTK band (authored tiers only); Rule 3b material→source-template mapping (owner)stages 2, 5, 7; ItemDef.SourceTemplates owned by Crafting (QQ-08 resolved)
base-buildingEC17 (12 rows) / AC-55–64 incl. AC-63b/63c depositPoint; AC-2 allow-list; AC-45/46(+b) CI gatesstages 6, 7, 10
enemy-aiRule 9 density monotonic and deep > shore per template (both warnings while provisional); Rule 6 cap fixed at MVP (reject); telegraph floor inheritedstages 7 and 8
land-dock-undockRule 4 sole-source warning; Rule 7/8 traversalProfile + socket layout per template; Rule 3 seeded starting templates (data: manifest.startingTemplates)stage 7
player-movement / camera / inventory / saveschema-negatives; safe-range warnings; knob filesstage 9
save-persistence (ADR-0004)Rule 4 configVersion gate input; tide blockmanifest configVersion; socketCount amendment
coding-standardsgameplay values data-driven, never hardcodedevery knob in a file; CI gates recompute from records
architecture Principle 5fail loudly at load, never silently at runtime§4

Performance Implications

Migration Plan

No production code. (1) IDataReader + Newtonsoft implementation (shares ADR-0004's reader adapter); (2) manifest.json + tide.json with ADR-0001's validator as stage 1; (3) GameDataLoader.Load from the SubsystemRegistration hook + the override folder + the fatal-config screen; (4) CI EditMode test running Validation.Run over assets/data/ + the warning snapshot; (5) apply the ADR-0004 amendment (§1 socketCount, §5 step 3 epoch check) and update the registry's save_file stance in the same change; (6) one file + validator per system as its ADR lands; (7) stage 10 gates once harvesting/crafting/BB data exist.

Validation Criteria

Related Decisions

Amendments

2026-09-03 — §1 source-folder path (foundation-game-data story-008)

§1 resolved the repo source folder as one level above Application.dataPath. That predates the decision to nest the Unity project under UnityProject/; from inside the project, assets/data/ is two levels up: Path.Combine(Application.dataPath, "..", "..", "assets", "data"). The single-.. form lands one level short and reads as a missing folder. The path is now resolved in exactly one place in the test assembly (SourceData.Root), guarded by one standalone Directory.Exists test that fails alone if the project moves; the Editor mirror (DataMirrorBuildStep.SourceRoot, story-007) uses the same two-level form. The CI gate (CiDataGateTests) runs GameDataLoader.Load — and therefore the one Validation.Run — over that folder; warnings are compared to tests/fixtures/validation-warnings.snapshot (stage|file|path|rule per line); stage 10 is asserted vacuous until Migration step 7 lands rows, so "zero breaches" is visibly not yet a claim.

2026-09-04 — stage 9 gains its first rows (foundation-input story-004)

input.json (already in §1's file list and row 9's title since ADR-0007's acceptance) is now read by InputConfigReader and validated by InputRules.Stage9 on the existing Knobs slot — stage 9 is no longer vacuous; the story-004 tripwire lists {2,3,5,6,7,8,10}. input.json is not yet in ManifestRules.RequiredFiles (an absent file is input.notLoaded at stage 9, fatal); foundation-input story-005 adds it when the adapter that needs it lands.

(Folded in 2026-09-08 from the stray docs/architecture/ADR-0006-game-data-loading.md, which had been written to the wrong filename — review U2, review-fixes story-008.)

Amendments — 2026-09-07 (architecture-review-2026-09-07.md)