ADR-0019 -- Pull-from-demand asset pipeline: the pack is a function of declared demand

Status: ACCEPTED · Decided: 2026-08-03
These are the actual decision records the game is built from, published unedited except for internal process notes. They describe why the game works the way it does. They are not a strategy guide, and some of them argue with each other.

Context

The failure that earned this decision (measured 2026-08-03)

Running tools/art_review/apply_review.py promote today copied 202 files into godot/assets/, adding 228 MB and taking the directory from 46 MB to 275 MB -- a ~5x pack increase, reverted before commit. What it copied were MASTERS: tex_bakelite_cracked_1024.png, crt_frame_bezel_heavy_1536.png. A 1024px master backing a 64px icon is 256x the pixels the game ever draws. The promote would also have violated docs/art/ART_MASTERS_POLICY.md (art over 1MB never goes in git) roughly 200 times in one commit.

This is not a new failure; it is issue #787 recurring: ~488MB of unreferenced hi-res icon variants once bloated the build the same way. The generator of both incidents is the same two facts:

  1. godot/export_presets.cfg sets export_filter="all_resources" in all three presets -- Godot packs the ENTIRE godot/ tree. Nothing is excluded by reference. Whatever lands under godot/ ships.
  2. The pipeline is push-and-copy: approval upstream (a review verdict, a category map) directly produces files under godot/, with no step that asks whether the game will ever draw them.

Measured today: godot/assets is 47 MB of a 59 MB pack -- assets are ~80% of the player's download. And the review-verdict store held 1,021 verdicts (807 keep / 214 iterate) of which only 202 could actually move, because the hand-maintained category->destination map had fallen behind and pixellab paths did not resolve. The tool reported a confident keep=807 while 75% of it could not move -- the same silent-wrongness shape as the hollow CI gate (ADR-0017): a confident signal uncorrelated with reality.

The four states

Three states are intended:

StateWhereAdmitted by
Generatedart_generated/ (gitignored)the pipeline ran
Libraryart_source/ (<=1MB, in git) + masters archive per ART_MASTERS_POLICYPip's taste. No justification needed
Packedgodot/assets/declared demand

The fourth state was discovered by measurement today: packed-but-undemanded -- bytes in the pack that no game mechanic will ever instantiate. The current architecture admits this state freely (both #787 and today's promote landed the tree in it), and every guard we have is downstream of it already existing.

A second distinction rides on the table: Library holds MASTERS; Packed holds GAME-READY DERIVATIVES. A master crossing into godot/ unchanged is itself a defect, independent of whether it is demanded.

The ruling

Pip, 2026-08-03 (verbatim): "no asset can get generated and promoted INTO the game's library by a mechanism which won't allow receipt of an asset into the game's library without something like a mechanically verified reason for it to be instantiated in the game at least once... 'Pip wants an image' might arrive in his brain, or I might come up with things spontaneously that are cool, and say 'yes promote these to Library status' and they can be like that. But then to get them INSIDE A SPACE IN THE GAME there has to be a GAME MECHANICS element that says 'at this point I want to instantiate e.g. [pot plant] and therefore I need [pot plant large 1] and [pot plant large 2]', the game registers a LACK of [pot plant large 2], and then there is a mechanism by which it is selected from Library and pulled into where it is meant to be for the build."

Decision

The pack is a FUNCTION of declared demand, not an accumulation of past approvals. Assets flow pull-from-demand:

  1. Library admission stays taste-gated. Anything Pip likes enters the Library with no justification. The Library may grow without bound; it is outside godot/ and (above 1MB) outside git, so its growth costs nothing in the pack.
  1. Pack admission is demand-gated, structurally. A DEMAND MANIFEST declares what the game instantiates. The only path into godot/assets/ is the pull step, which reads the manifest, selects from the Library, and renders a derivative into place. "Packed but undemanded" is thereby UNREPRESENTABLE, not merely rejected -- there is no promote-without-demand path to misuse. This is the same shape as GameConfig.is_ranked_run() (godot/autoload/game_config.gd:597) and the #1058 difficulty lock: the rule lives in the only path that exists, not in a checker bolted alongside. (That function's own comment names the failure mode of the alternative: a second write site "that forgets this check silently reopens the hole.")
  1. Demand is declared as POOLS, not file lists. The dynamic loaders read directories and construct paths at runtime -- portrait_library.gd:40 builds "%s%s_%d.png" % [PORTRAIT_DIR, stem, PORTRAIT_SIZE]; worker_variant_pool.gd:89 loads whatever variants its data names. A file-list manifest would re-invent exactly the blindness that makes static scanning unusable (see Rejected alternatives). A demand entry therefore reads like: "office props: >=2 large pot plants at 96px" or ">=6 researcher portraits at 128px" -- a pool, a floor, a size.
  1. Demand declares the SIZE the game instantiates at. The pull step renders a game-ready derivative (resize/compress/format) from the Library master. Promotion is a TRANSFORM, never a copy. The master never enters godot/, so the 1024px-master-behind-a-64px-icon failure and the ART_MASTERS_POLICY violation are both impossible by construction, not by vigilance.
  1. Unmet demand is a GENERATION REQUEST, surfaced loudly and forward -- never a placeholder texture. When the manifest demands what the Library cannot supply, the gap is reported where it is actionable (the audit output, the build log, a filed request for the generation pipeline), not papered over in the render. Issue #796 (the magenta cat) is the anti-pattern: a deliberate placeholder that shipped because absence was represented as a texture instead of as work. This clause also closes issue #1092 (events name physical things the office never shows -- install a security system, no cameras appear) by construction: the event's demand entry for cameras is unmet, and the gap is on a list someone reads, rather than waiting for a player to notice.
  1. A two-direction audit backstops the structure. It reports BOTH packed-but-undemanded (bytes in godot/assets/ no manifest entry accounts for) and demanded-but-missing (manifest entries the pack does not satisfy), and FAILS the build when unaccounted bytes exceed a budget. It NEVER deletes. The audit is a drift detector for the transition period and for anything that bypasses the pull path (hand-added files, merge accidents); the pull path itself is the enforcement.

What is NOT decided here

Beacons served / violated

Interaction contract

Rejected alternatives

Consequences / open questions

This matters more than it reads. It decides whether the audit's FIRST RUN is a report or a crisis. godot/assets is 47 MB today and predates any manifest, so on day one essentially all of it will be undemanded -- not because it is unused, but because nothing has declared it yet. Without grandfathering, the audit would open by failing the build over the entire existing pack, and the rational response would be to raise the budget until it passed, which is how a gate becomes decoration. Grandfathered, the same number is a backlog with a size attached.