# ADR 0010: LDtk tileset source hash (drift detection)

**Date:** 2026-05-20
**Phase:** 07 (Workflow Smoke + Convention Locks, Plan 07-02)

[doc->REQ-MAP-02]

## Status

**Accepted** — locks the `tilesetSourceHash` convention as **sha256 hex carried
in a sidecar JSON file** alongside each `.ldtk`. Re-evaluation gates: (a) any
Phase 8 `LdtkLoader.ts` implementation reveals carrier ambiguity that the
sidecar shape cannot absorb, or (b) LDtk 1.6+ ships native `customFields[]`
support on `TilesetDef` (verified absent in 1.5.3). Absent either trigger,
this ADR is locked through v1.2.

The Phase 8 `LdtkLoader` (out of Phase 7 scope) inherits the HARD-throw
posture mandated below — no silent fallback, no degraded-mode load.

## Context

LDtk's atlas PNG and the `.ldtk` file's tileset reference can drift silently:
a designer re-exports the atlas (pixel bytes change) and LDtk happily continues
loading the old `TilesetDef.uid` against the new image. Tile coordinates that
formerly indexed grass now index water. No error is raised. No version marker
bumps. Phase 8's runtime would render mismatched tiles without any forcing
function for the operator to notice — exactly the silent-degradation failure
mode that CLAUDE.md Hard Rule 5 ("39dll wire protocol = call order") and
the broader "extract → document → rewrite" discipline are designed to
prevent.

The crypto-sibling precedent is
[ADR 0004 — Room Hot-Reload](0004-room-hot-reload.md). Ed25519 signature
verification on every room layout load throws on mismatch rather than
degrading: a tampered intermediate cannot inject a forged room because the
signature carries `sha256(layout_bytes)` and the verify step refuses to
load on bad sig. ADR 0010 applies the same fail-loud posture to the
atlas-PNG ↔ `.ldtk`-tileset binding.

### Q1 ambiguity history (resolved 2026-05-20)

The original Phase 7 CONTEXT.md draft proposed embedding the hash in
`TilesetDef.customFields[]` — a property the v1 author believed existed on
LDtk's `TilesetDefinition`. Phase 7 RESEARCH verified against the published
`LDTK_JSON_SCHEMA.json` (LDtk 1.5.3) that **`TilesetDefinition` has no
`customFields` array**. Only `EntityDefinition` and `LayerDefinition` carry
`customFields[]`. The CONTEXT.md v1 claim was incorrect.

The operator resolved Q1 on 2026-05-20 to **Option B (sidecar JSON file)**
per commit `5046393`. This ADR formalizes that decision. The carrier
question is not re-litigated below; the Decision section locks it.

## Decision

Three sub-decisions, implementing CONTEXT.md D-05, D-06, D-07.

### 1. Hash algorithm = sha256 (D-05)

Hash algorithm = **sha256** (hex-encoded, lowercase, 64 characters). Node's
built-in `crypto.createHash('sha256')` is the sole implementation surface;
zero new dependencies. The choice matches the Ed25519-payload-hashing era
already established in `tools/room-converter` (ADR 0004 hashes
`layout_bytes` via sha256 before signing).

### 2. Carrier = sidecar JSON file (D-06)

Carrier = **sidecar JSON file**, one per `.ldtk`. Naming:

- `maps/<world>.ldtk` is paired with `maps/<world>.tileset-hashes.json`.

Sidecar schema (literal — Phase 8 `LdtkLoader` parses this verbatim):

```json
{
  "<TilesetDef.uid>": "<sha256-hex>"
}
```

- **Keys** are LDtk `TilesetDefinition.uid` integers cast to string (LDtk
  emits these as integer values; JSON object keys must be strings).
- **Values** are 64-character lowercase hex sha256 digests of the
  referenced PNG bytes (the file at `TilesetDefinition.relPath` resolved
  relative to the `.ldtk`).

Per-tileset granularity is preserved (the D-06 intent): each
`TilesetDefinition` carries its own hash, so a re-export of one atlas
does not invalidate the others.

The sidecar shape was chosen over the originally-hypothesized embedded
field because:

- LDtk 1.5.3's `TilesetDefinition` has no `customFields[]` array (verified
  against `LDTK_JSON_SCHEMA.json`).
- A sidecar keeps the `.ldtk` editor-clean and survives LDtk-editor
  round-trips without the editor stripping unknown keys.
- The sidecar is trivially scriptable: a future `tools/scripts/`
  update-helper can rewrite the JSON deterministically without touching
  the `.ldtk` itself.

### 3. Drift behavior = HARD throw + refuse to load (D-07)

Drift behavior = **HARD throw + refuse to load**. The Phase 8
`LdtkLoader.ts` (out of Phase 7 scope) asserts at init that for every
`TilesetDefinition` referenced by the loaded `.ldtk`:

1. The sidecar file exists at the paired path above.
2. The sidecar contains an entry keyed by the tileset's `uid`.
3. The recorded sha256 hex matches the live sha256 of the referenced
   PNG (resolved from `apps/client/public/atlas-mvp.png` or the
   extracted-sprite path that the `.ldtk`'s `relPath` resolves to).

Any of those three checks failing throws synchronously with a message
naming:

- The drifted tileset's `uid`.
- The expected sha256 hex (from the sidecar).
- The got sha256 hex (live computed) — or "<missing>" if the sidecar
  entry is absent.
- The sidecar path.

No fallback. No degraded-mode load. No warn-and-continue. This matches
ADR 0004's Ed25519-sig-fail posture (fail-loud, refuse silent
degradation) and CLAUDE.md Hard Rule 1's discipline against trusting
unverified inputs.

## Consequences

### Positive

- **Silent atlas drift becomes impossible.** A re-exported atlas with
  pixels that don't match the recorded hash causes Phase 8 load to
  throw immediately — the forcing function is the throw itself.
- **Matches Ed25519-room-sig discipline.** The fail-loud posture is
  already proven in ADR 0004 + `tools/room-converter verify`; ADR 0010
  applies the same shape to the asset-pipeline boundary.
- **Zero new dependencies.** Node `crypto` is built-in; the sidecar is
  plain JSON parsed by `JSON.parse`.
- **Editor-clean `.ldtk` files.** No unknown fields injected into LDtk's
  serialized output; the editor round-trips cleanly across save cycles.
- **Per-tileset granularity.** A re-export of one atlas does not force
  re-hashing of unrelated tilesets in the same world.

### Negative

- **Operator must re-run a sha-update script after each atlas
  re-export.** Phase 8 ships the script under `tools/scripts/` (likely
  `tools/scripts/update-tileset-hashes.mjs`); Phase 7 only locks the
  contract. Until that script lands, the throw-on-mismatch posture is
  the operator's forcing function — the deploy gate (`pnpm preflight`
  step 6 `lint:atlas-hash`) catches stale sidecars before
  `flyctl deploy`.
- **Two files to keep in sync per zone.** `.ldtk` + sidecar JSON. The
  sidecar travels with the `.ldtk` in git; the structural drift guard
  (Phase 8 `lint-atlas-hash.mjs` activation) is the convention's
  enforcement surface.
- **Sidecar adds one file per zone.** For the v1.1 minimap-pipeline
  scope (single `TestBed_001.ldtk` in Phase 8, `Online_Lobby` in Phase
  12) that's two sidecars. For the v1.2 full-zone catalog (BNCentral
  chunked + ~15 other rooms) it would scale to ~16 sidecars — still
  trivially auditable.

### Neutral

- **Sidecar lives in `maps/` next to the `.ldtk`.** Git history shows
  hash bumps explicitly as JSON-line diffs, making atlas-re-export
  events visible in `git log`.
- **Sidecar is opt-in per `.ldtk`.** A `.ldtk` without a paired sidecar
  is a hard failure at load time — there is no "no-hash mode."
  Phase 8's `lint-atlas-hash.mjs` no-op when `maps/` is empty (Phase 7
  baseline) becomes a real guard once the first `.ldtk` lands.

## Alternatives considered

- **LDtk `TilesetDef.customFields[]`** (the CONTEXT.md v1 hypothesis) —
  **Rejected:** does not exist in LDtk 1.5.3's schema. Only
  `EntityDefinition` and `LayerDefinition` carry `customFields[]`.
  Verified against the published `LDTK_JSON_SCHEMA.json`. Adopting
  this carrier would have required either patching LDtk (out of scope
  for an editor we do not own) or storing the hash in a property the
  editor would silently drop on save.
- **Inline hash in `.ldtk` `customLevelFields`** — **Rejected:**
  per-Level granularity is coarser than per-Tileset. Multiple Levels
  in a world may share a Tileset, which would force the hash to be
  duplicated (and kept in sync) across every Level using it. The
  per-Tileset sidecar key avoids the duplication.
- **Embed hash in the PNG filename** (e.g.
  `atlas-mvp.<hex>.png`) — **Rejected:** requires every reference path
  in the `.ldtk` to change on re-export. Breaks LDtk editor's
  resolved-path caching and forces the designer to re-link the atlas
  inside the editor on every change. Operationally painful with no
  upside over the sidecar.
- **Git-LFS pointer hash** — **Rejected:** introduces git-LFS as a
  dependency for what is fundamentally a 100-line problem. The repo
  doesn't otherwise use LFS; the sidecar is solving a content-binding
  question, not a storage question.

## References

- [`docs/adr/0004-room-hot-reload.md`](0004-room-hot-reload.md) —
  sibling crypto/integrity ADR; Ed25519 + sha256-payload precedent
  for the fail-loud posture mirrored here.
- [`.planning/phases/07-workflow-smoke-convention-locks/07-CONTEXT.md`](../../.planning/phases/07-workflow-smoke-convention-locks/07-CONTEXT.md)
  §"tilesetSourceHash ADR" D-05..D-08 — the locked-decision source
  for this ADR's three sub-decisions.
- [`.planning/research/v1.1/PITFALLS.md`](../../.planning/research/v1.1/PITFALLS.md)
  — Pitfall #2 (tileset hash drift) — the failure-mode this ADR
  mitigates.
- LDtk JSON Schema: https://ldtk.io/files/LDTK_JSON_SCHEMA.json —
  authoritative source confirming `TilesetDefinition` has no
  `customFields[]` array in 1.5.3.
- Commit `5046393` — `docs(07): resolve Q1 — ADR 0010 carrier =
  sidecar JSON (Option B)` — operator's Q1 resolution on
  2026-05-20 selecting the sidecar carrier.
