Tactical Action · Architecture

Build, Data & Content Pipeline

A focused page within the Tactical Action Architecture documentation. The full map and every sibling page live in the Architecture hub.

5sections12 minread1diagram

On this page

Everything the other V4 pages describe — six genre cores in one engine, the netcode, the crowds, the operator roster — eventually has to become a shippable package on ten platforms and stay inside a console frame budget, and it has to do so from numbers a designer can edit without a programmer recompiling. This page is V4's production substrate: the BuildGraph/cook/patch pipeline that turns source into platform packages, the data architecture (typed DataTables plus a telemetry-backed balance ledger) that holds the thousands of tuning numbers, and the content pipeline that lints and gates every asset before it can ship. It is the least glamorous and most load-bearing layer in the project — a data-table typo that escapes the validators is a balance bug in everyone's hands, and a broken asset reference that escapes the linter is a cook failure on the build farm.

There is one fact about V4 that this page exists to state honestly, because it shapes everything below it: this repository is text-only — no Perforce binary depot is attached. Every Unreal content asset is therefore a JSON stand-in (<Name>.uasset.v4asset.json / <Map>.umap.v4asset.json), not a cooked binary, and the build/cook/patch pipeline is real configuration plus real validators rather than a build farm that has actually produced shipping packages from this tree. That distinction is drawn precisely throughout, in the spirit of the orientation hub it expands on — ../V4_ARCHITECTURE.md.

What ships, honestly#

Three layers, stated plainly.

  • Real config, real validators — no cooked output. The build pipeline is described by genuine, parseable files: a BuildGraph script (V4/release/BuildGraph/V4BuildCookPatch.xml), a manifest (V4/release/build-cook-patch.json), a Jenkinsfile.pc, a GitHub Actions workflow (.github/workflows/v4-build-cook-patch.yml), and distributed-compile configs (FASTBuild.bff, SNDBS.ps5.xml). These are wired together and enforced by apps/v4/scripts/src/v4-build-cook-patch-check.mjs, which actually reads each file and asserts its contents. What the pipeline has not done in this repo is cook a shipping binary — there is no binary content to cook.
  • Real, enforced data architecture. The 19 DT_*.json data tables under V4/ue/Content are validated by v4-data-linter.mjs (required-field and schedule-graph checks), the 891 asset stand-ins by ue-asset-linter.mjs (naming, polygon, texture, reference, orphan, and plugin-cycle rules), and the balance datasets (public-balance-ledger.json, balance-operations.json, gadget-balance-ledger.csv) by v4-public-balance-ledger-check.mjs. These are domain-specific gates that fail on bad data, not shape-only smoke tests.
  • Honestly labeled content gap. Measured live in this tree: 0 real .uasset binaries, exactly 1 real .umap (V4Core/Maps/BootMap.umap, 15 KB, authored by create_boot_map.py), against 891 .v4asset.json stand-ins (547 .uasset + 344 .umap). The architecture doc records 770 as of 2026-06-12; the live count is now 891. Stand-ins carry authored intent (typed fields, references) and are what the gates attest to, but they cannot be cooked, loaded, profiled, or played. Any claim that requires cooked content — PSO caches, profiled frame rates, playable maps — stays open.

The honest summary: the process is real and the validators would catch regressions, but the binaries the process exists to produce are not in this repository.

Build, cook & patch#

Build system#

V4 is an UnrealBuildTool project with three real target rules under V4/ue/Source: V4.Target.cs (the game client), V4Editor.Target.cs, and V4TournamentServer.Target.cs (the dedicated/validator server the online-services backbone re-simulates on). Each is a genuine TargetRules subclass — V4.Target.cs sets DefaultBuildSettings = BuildSettingsVersion.V5 and IncludeOrderVersion = EngineIncludeOrderVersion.Latest, the UE5.5-era defaults. The codebase splits into 28 V4* C++ modules (V4Core, V4Gameplay, V4Tactical, V4Stealth, V4Tactics, V4RTS, V4ActionRPG, V4Procgen, V4Crowd, V4LevelOps, V4Netcode, V4OnlineServices, V4Persistence, V4Telemetry, V4Tests, and more), each with its own *.Build.cs ModuleRules. The dependency edges are real and meaningful: V4Gameplay.Build.cs declares PublicDependencyModuleNames of Core, CoreUObject, Engine, V4Core, GameplayAbilities, GameplayTags, and GameplayTasks — i.e. the GAS stack the GAS-layout page builds on, pulled in at the module boundary rather than ad hoc.

Distributed compile is configured for two backends, both gated: FASTBuild for Win64 (V4/release/distributed/FASTBuild.bff, worker pool v4-windows-buildfarm) and SN-DBS for PS5 and PS5 Pro (SNDBS.ps5.xml, restricted runner label ps5-sdk-sndbs). The check rejects the manifest unless FASTBuild covers Win64 and SN-DBS covers both PS5 SKUs, so the console build path cannot silently drop its distributed compiler.

Two CI surfaces drive builds. The Jenkins PC pipeline (Jenkinsfile.pc, agent ue5-pc-buildfarm) runs the eight-stage editor/cook/package/publish sequence for Win64/Linux/Mac. The GitHub Actions workflow covers the non-engine surfaces — tools, web, services — and runs pnpm -F @v4/scripts test, an Nx affected query, and cargo test --manifest-path apps/v4/Cargo.toml --workspace, so the TypeScript tooling and the Rust online-services workspace are tested on every PR touching V4/release/** or apps/v4/**.

Cook#

The cook itself is a BuildGraph graph, V4BuildCookPatch.xml, and it is small and explicit — four nodes wired by Requires:

  1. Build EditorCompile Target="V4Editor" Platform="Linux" Configuration="Development" (the Linux editor is what cooks headlessly).
  2. Cook PlatformCook with arguments -unversioned -compressed -iterate -ddc=$(ReferenceDDC), followed by a ValidateCook command.
  3. Author Differential Patch — a V4DiffPatch command feeding V4/release/patches/differential-patches.json.
  4. An Aggregate named BuildCookPatch tying all three together.

build-cook-patch.json enumerates ten cook platforms — Windows, Mac, Linux, PS5, PS5Pro, XSX, XSS, Switch2, iOS, Android — and the check (REQUIRED_COOK_PLATFORMS) fails if any are missing. Each platform carries a strip profile and a mode-plugin policy, which is where the multi-cell breadth meets the memory budget: desktops use DesktopFull with modePlugins: "all", PS5 uses ConsolePerformance, PS5 Pro ConsoleQuality, but XSS strips the R6-memory-heavy plugins (ConsoleMemoryConstrained / strip_r6_memory_heavy) and Switch2, iOS, and Android strip Warzone and R6 (HandheldConstrained / MobileHighEnd / strip_warzone_and_r6). The check requires every platform entry to declare all three of uatPlatform, stripProfile, and modePlugins, and rejects any cookTimeBudgetHours > 8 — the ≤ 8-hour-per-platform farm budget, enforced as data rather than a comment.

Patch#

Patching is governed by three datasets, each with a hard, domain-specific gate in v4-build-cook-patch-check.mjs:

  • Day-one patch (patches/day-one-patch.json): the budget is asserted to be exactly 6 GiB (6442450944 bytes); the estimate must not exceed it; the patch must carry no base-install content (contentInPatch === false); and it must include both an anti_cheat_update and a closed_beta_balance payload. A day-one patch that smuggles in base content, or omits the anti-cheat delta, fails the build.
  • Hotfix (hotfix/data-table-hotfix.json): the channel must be patch-service, global rollout must be ≤ 30 minutes, the only allowed payload type is data_table, and the dispatch must be signed and rollbackable. This is what lets a balance change ship without a binary update — and the gate guarantees a hotfix can never quietly become a code push.
  • Differential patches (patches/differential-patches.json): authoring must be enabled for all ten launch platforms and must include the five required steps — chunk_manifest, binary_delta, data_table_delta, version_graph, and rollback_manifest.

Data architecture: DataTables and the balance ledger#

This is the part most worth understanding, because it is what makes a six-genre game tunable at all without recompiling C++.

DataTables as the data spine#

Tuning lives in typed JSON DataTables under V4/ue/Content (19 DT_*.json today), and v4-data-linter.mjs enforces a per-type required-field schema via its REQUIRED_FIELDS map. The fields are domain-specific, not generic: a DT_Operator row must carry displayName, faction, mesh, and voiceLibrary; a DT_Weapon needs recoilCurve, damageTable, and fireRate; a DT_Takedown needs compatibleCells, animationPair, and soundProfile; a DT_ProcgenChunk (the chunks the world-streaming & procgen solver assembles) needs cellId, graphId, asset, and constraintTags; a DT_WorldPartitionProfile needs cellSizeMeters, streamingRingRadiusCells, and hlodBudgetMegabytes. An empty table or a row missing any required field is an error, not a warning.

DT_Schedule gets a graph validator of its own, because Hitman-style NPC routines are state machines, not flat rows. validateSchedule requires every schedule to have nodes, rejects any next edge pointing at a missing node id (schedule-orphan-node), and requires the graph to close back to its start node (schedule-closed-cycle) — an NPC routine that never loops is a bug. The shipped DT_Schedule_Launch.json rows model exactly this: Arrive → Routine → Social → Reset → Arrive, a closed daily cycle the linter proves is well-formed.

Gameplay tags#

Ability activation, stance, detection, and faction state are keyed off a single hierarchical gameplay-tag tree authored in V4/ue/Config/DefaultGameplayTags.ini and validated against the REQUIRED_GAMEPLAY_TAGS list in gameplay-tags.mjs. The hierarchy is per-cell and deep — Tactical.State.Stance.{Stand,Crouch,Prone}, Tactical.Cover.{Left,Right}, Stealth.Detection.{Hidden,Suspicious,Compromised}, ARPG.Combat.Parry.Window, RTS.Faction.{Vanguard,Synapse,…} — so the same tag a designer types in a data table is the tag the C++ ability system reacts to, with no string drift between them.

The balance ledger: telemetry-backed source of truth#

Where V2 made a frame-data spreadsheet the source of truth, V4's analog is a publicly published, telemetry-evidenced balance ledger, and it is treated with the same rigor. v4-public-balance-ledger-check.mjs enforces a chain of domain-specific rules:

  • Evidence gates. The ledger's telemetryEvidence must specify a sample window ≥ 7 days, a minimum match count ≥ 10,000, a dashboard path, and the four metrics pickRatePercent, winRateDeltaPercent, counterplaySuccessPercent, matchCount. Every individual change must cite a metricSource and a match count at or above that floor — a balance note without telemetry behind it fails.
  • Balanced change set. Entries must include both Buff and Nerf changes, each with target, before, after, and rationale — the ledger cannot be all-buffs marketing copy.
  • Designer's Tier List. At least six ranked entries spanning Operator, Faction, and Civilization subject types, each tier in {S,A,B,C} with telemetry (matchCount ≥ 7500, pick/win rates, a trend).
  • Matchup matrices. Three are required by id — operator-vs-operator, faction-vs-faction, civ-vs-civ — and the check validates matrix algebra: winRates and matchCounts must be rectangular against the declared rows and columns, every win rate in [0,100], every cell's sample at or above the matrix minimum.
  • Wukong frame data. The ARPG cell publishes parry/dodge/startup frames, and the gate hard-codes the correctness values: a heavy parry must stay at parryWindowFrames.width === 6 and dodge i-frames at dodgeIFrameFrames.width === 9. This is a real domain assertion — it would fail if the numbers drifted.

The same gate cross-checks the service manifest, the marketing /balance-ledger page, its Playwright e2e coverage, and the architecture/features/TODOS docs, so the published numbers, the site, and the documentation cannot disagree. The weekly cadence and tier definitions live in balance-operations.json, and a machine-readable gadget-balance-ledger.csv carries per-gadget launch baselines (charges, cooldown, radius, noise, counterplay window) one row per gadget. The dashboards behind those metrics belong to telemetry, observability & performance.

flowchart TD subgraph Authoring [Authored source, text-only] DT[19 DT_*.json DataTables] TAGS[DefaultGameplayTags.ini] LEDGER[public-balance-ledger.json<br/>balance-operations.json] STANDIN[891 .v4asset.json stand-ins] end subgraph Gates [CI validators · v4/scripts] DLINT[v4-data-linter.mjs<br/>required fields + schedule graph] ALINT[ue-asset-linter.mjs<br/>naming · poly · refs · cycles] BAL[v4-public-balance-ledger-check.mjs<br/>telemetry evidence + matrices] BCP[v4-build-cook-patch-check.mjs<br/>manifest + budgets] end subgraph Pipeline [Build pipeline · config only] BG[V4BuildCookPatch.xml] MAN[build-cook-patch.json<br/>10 platforms · strip profiles] PATCH[day-one ≤6GiB · hotfix · differential] end DT --> DLINT TAGS --> DLINT STANDIN --> ALINT LEDGER --> BAL MAN --> BCP BG --> BCP PATCH --> BCP DLINT --> CI{PR gate} ALINT --> CI BAL --> CI BCP --> CI CI -.->|no binary depot attached| COOK[(cooked .uasset / .umap<br/>NOT in repo)]

Content pipeline & the stand-in model#

The .v4asset.json stand-in convention#

Because no Perforce binary depot is attached, every content asset the docs and gates reference exists as a JSON sidecar that records the asset's authored intent: typed fields, row data, and references to C++ symbols and to other stand-ins. A representative example is V4Rendering/DA_ShaderWarmupPlan.uasset.v4asset.json, which declares an assetType of DataAsset, a backing class V4ShaderWarmupPlan, PSO precache lists, per-cell prewarm triggers, and a regression policy (failBuildOnMissingEntry: true, missingPsoEntryBudget: 0). The sidecar describes a real plan the engine would execute; it is not itself a cooked PSO cache. This is the disciplined, honest representation of real-but-not-yet-binary content, and it is the unit the gates attest to.

Asset linting#

ue-asset-linter.mjs treats stand-ins and real binaries identically (it normalizes both to a content key) and applies the full asset-pipeline rule set:

  • Naming. A TYPE_PREFIXES table maps each Unreal asset type to its required prefix (T_ textures, M_/MI_ materials, BP_/WBP_ blueprints, SM_/SK_ meshes, A_/ABP_ animation, DT_/DA_ data, PCG_ graphs, NS_ Niagara, IA_/IMC_ input, L_/LVL_ levels). An asset with no matching prefix is an error; the one real binary, BootMap, is explicitly whitelisted.
  • Budgets. Textures over 4096 px are an error and over 2048 px a review warning. Meshes are checked against per-archetype polygon budgets (MESH_POLY_BUDGETS): Operator 100k, Hero 150k, Civilian 40k, RTSUnit 12k, ArcadeSprite 2k, Vehicle 120k, Prop 25k. Animations must have compression enabled and no uncompressed tracks.
  • Graph integrity. Broken references (a references entry that resolves to no known asset) are errors; non-primary blueprints with zero incoming references are flagged as orphans; widget text without a localization key fails; IA_Move must include the UV4CoverStickInputModifier; cover-anchor graphs must declare a mapId, ≥ 2 anchors with 3D location and normal, and edges that only reference existing anchors.
  • Plugin cycles. .uplugin descriptors are parsed into a dependency graph and walked with a depth-first visit that reports any dependency cycle — the guardrail for the GameFeature-plugin DLC strategy below.

The companion v4-data-linter.mjs and ue-asset-linter.mjs are exactly the "PR-gate asset linter" the architecture hub lists, and they run before any cook is even attempted, so a missing-field row or a dangling reference is caught as a CI failure rather than a cook-farm crash.

Source control, plugins & the one real map#

Source control is split by content type: Git for source code, config, and scripts (V4/ue/Source/, V4/ue/Config/, plus tools/, web/, services/), Git LFS for small text-y binaries, and Perforce for the heavy binary depot — the depot that, in this repository, is not attached, which is precisely why the stand-in model exists. Post-launch content rides Unreal's GameFeature plugin system: each new operator, map, or cell ships as its own plugin (the same hot-swap mechanism the game-modes & live-service page covers), and the asset linter's cycle check keeps those plugin dependencies acyclic.

The single exception to the text-only rule is worth calling out as the honest counterexample: V4/ue/Content/V4Core/Maps/BootMap.umap is a real, 15 KB, engine-loadable .umap, generated by apps/v4/scripts/src/create_boot_map.py through the live unreal Python API — it spawns a PlayerStart, a DirectionalLight, and a SkyAtmosphere, then saves the package. It is the one piece of V4 content the engine can actually load, and it stands as the proof-of-concept for what the stand-in pipeline would produce against an attached editor. The art, audio, and VFX budgets these gates enforce — and the soft assets they reference but do not bake — belong to presentation pipelines; the cooked binaries themselves remain the project's open frontier.