Factory: Mass Architecture
Greenfield rewrite of the factory simulation. Machines and Operations become Mass entities; actors are a thin representation layer. Targets 1k+ machines and 10k+ concurrent operations, server-authoritative with Iris/Horizon replication.
🗺 Overview
The current FactoryFloor/ module is actor-based: AMachineBase subclasses each run their own tick/state-machine, coordinated by AFactoryFloorManager and a suite of subsystem managers. At target scale this doesn't hold up.
The new module (FactoryFloor2/) moves everything into Mass. Machines and Operations are entities; actors become a thin visual/input layer. Parts stay as handles behind a central registry. The scheduler runs as an ordered chain of server-side processors.
FactoryFloor2/ is built green alongside the old module. Both compile until a single cutover PR removes FactoryFloor/. No incremental migration — one clean swap.What stays the same
LogisticsBots are already on Mass (Source/Dropforge/Mass/Logistics/ + Mass/Navigation/) and replicate via the HorizonReplication plugin. The new module reuses those exact patterns. Logistics-bot code itself is not rewritten.
The 8-state operation lifecycle, MWKR dispatch algorithm, and JIT-pull scheduler are specified in the Production Scheduling doc. This doc covers the Mass implementation of that design.
Server-authoritative simulation. Operations are server-only. Clients receive a compact machine-state snapshot via Horizon proxies and reconstruct visible work state from that alone.
📁 Module Layout
The new module lives entirely under Source/Dropforge/FactoryFloor2/.
Source/Dropforge/FactoryFloor2/
Actors/
MachineActor.h/.cpp // thin AActor: mesh, click, footprint, FMassEntityHandle back-ref
MachineActorRepresentation.h/.cpp // arm-pose interpolation; ISM/anim driven by fragment reads
Mass/
Fragments/
MachineFragments.h // identity, slots, progress, modifiers, actor ref, rep state
OperationFragments.h // operation data, DAG links, progress, MWKR cache
PartHandleFragment.h // FPartHandle (uint32) + small inline arrays
Tags/
MachineTags.h // FActiveMachineTag, FMachineWorkingTag, FHorizonRepDirtyTag
OperationTags.h // one tag per Operation state (Queued, InProgress, ...)
Traits/
MachineServerTrait.h/.cpp
MachineClientTrait.h/.cpp
OperationServerTrait.h/.cpp // ops are server-only; not replicated
Processors/Server/
OperationReadinessProcessor // WaitingForParts → Queued when inputs land
MWKRRecomputeProcessor // memoised DAG walk; runs only on op-graph changes
MachineDispatchProcessor // the JIT-pull scheduler loop
MachineWorkProgressProcessor // parallel: accumulate progress on InProgress slots
OperationCompletionProcessor // InProgress → SenderAwaitingExport; register output handles
LogisticsHandoffProcessor // SenderAwaitingExport ↔ ReceiverAwaitingDelivery; floor-part retry
MachineLifecycleProcessor // on machine entity removal: reset ops, drop parts to floor
Processors/Client/
MachineRepresentationProcessor // drive ISM/animation from replicated fragment state
Registry/
PartRegistry.h/.cpp // UPartRegistrySubsystem (see Part Safety section)
Spawner/
MachineSpawner.h/.cpp // replaces FactoryFloorManager placement entity path
Management/
FactoryFloorSubsystem.h/.cpp // grid/placement only — no work dispatch
BotOrderSubsystem.h/.cpp // ingest player Bot Orders, build Operation DAGs
Replication/
MachineEntityProxy.h/.cpp // extends UHorizonEntityProxy
DataTypes/
OperationData.h // FOperationState enum, FRecipeStep, FProcessSpeed
MachineData.h // ported UMachineData (verbs, slots, layout, ISM)
⚙️ Machine Entity
One Mass entity per machine. All fragments are server-side unless noted. The actor is a thin back-reference — no game logic lives there.
Fragments
| Fragment | Contents |
|---|---|
| FMachineIdentityFragment | MachineDataPtr, supported verbs bitmask, SlotCapacity, base speed multipliers per process, grid cell |
| FMachineSlotsFragment | Fixed-size array of {FMassEntityHandle Op, uint8 State} — size = SlotCapacity, capped at a compile-time max (e.g. 8) to keep the fragment POD |
| FMachineProgressFragment | float Progress[SlotMax] and float EffectiveDuration[SlotMax] |
| FMachineModifiersFragment | Current power/temp/adjacency multipliers; updated by environment processors |
| FMachineActorFragment | TWeakObjectPtr<AMachineActor> — back-ref for click handling and visuals (mirrors FLogisticsBotActorFragment) |
| FMachineRepStateFragment (replicated) | Compact slot/progress snapshot for clients, mirrored via Horizon proxy |
Tags
FActiveMachineTag — machine is powered and eligible for dispatch.
FMachineWorkingTag — presence-only; drives the work-progress query.
FHorizonRepDirtyTag — existing pattern; marks the proxy for replication.
🔗 Operation Entity
One Mass entity per operation step. Server-only — operations do not replicate; clients infer work state from machine fragments.
Fragments
| Fragment | Contents |
|---|---|
| FOperationFragment | BotOrderID, RecipeStep, RequiredVerb, PredictedDuration |
| FOperationLinksFragment | TArray<FPartHandle, TInlineAllocator<4>> Inputs/Outputs; TArray<FMassEntityHandle, TInlineAllocator<2>> Upstream/Downstream op handles |
| FOperationProgressFragment | Progress, AssignedMachine: FMassEntityHandle, SlotIndex |
| FOperationMWKRFragment | Cached MWKR value + dirty flag |
State Tags
Exactly one tag per operation at all times — mutually exclusive. Tag transitions are the primary mechanism processors use to filter their work.
| Tag | Corresponding State |
|---|---|
| FOpWaitingForPartsTag | WaitingForParts |
| FOpReceiverAwaitingDeliveryTag | ReceiverAwaitingDelivery |
| FOpWaitingForMachineTag | WaitingForMachine |
| FOpQueuedTag | Queued |
| FOpInProgressTag | InProgress |
| FOpSenderAwaitingExportTag | SenderAwaitingExport |
| FOpCompleteTag | Complete |
🔐 Part Safety
Parts are handles, not entities. UPartRegistrySubsystem (a UWorldSubsystem) is the sole source of truth for every part in the factory. This gives the performance of handles — no Mass-entity overhead at 10k+ parts — with stronger lifetime guarantees than the current implicit-ownership TArray soup.
Record structure
struct FPartRecord {
EPartType Type;
EPartOwner OwnerKind; // Slot | Bin | BotCargo | Floor
uint64 OwnerKey; // packed: (entity handle | bin id | bot entity); unused when Floor
uint32 Generation; // increments on every owner change
// valid only when OwnerKind == Floor:
FVector FloorPosition;
FMassEntityHandle PendingDestination; // receiving op handle; null = orphaned → needs re-dispatch
};
TSparseArray<FPartRecord> Records;
FPartHandle = {uint32 Index, uint32 Generation}. Every read goes through Resolve(FPartHandle) → FPartRecord*. A mismatched generation is a stale handle — hard assert in Dev, log-and-drop in Shipping.
Safety invariants
- 1Every live
FPartHandleresolves to exactly one record. - 2Every record's
OwnerKeyis reachable from that owner (round-trip check). - 3
PartRegistryAuditProcessorruns every N ticks in Dev/Test builds: walks all machine slots, bins, and bot cargo fragments; rebuilds the owner→part map; asserts it matches the registry. Orphans are logged loudly. - 4Ownership transfers go through a single
TransferOwnership(Handle, NewOwner)call that bumps generation — no field-poking.
Floor recovery API
| Method | Purpose |
|---|---|
| DropToFloor(FPartHandle, FVector) | Sets OwnerKind=Floor, stores position, clears PendingDestination |
| GetOrphanedFloorParts() → TArray<FPartHandle> | Returns floor parts where PendingDestination is null or the destination op is no longer live; called by LogisticsHandoffProcessor each tick to re-issue pickup orders |
| AssignFloorPartToBotOrder(FPartHandle, FMassEntityHandle DestOp) | Sets PendingDestination; prevents double-dispatch |
📐 Scheduler Processor Chain
The scheduler is an ordered chain of server-side processors. Order is fixed. All run single-threaded except MachineWorkProgressProcessor.
Queries FOpWaitingForPartsTag. For each op, checks all Inputs[] are present and not in transit. On success: flip to FOpWaitingForMachineTag; mark MWKR dirty for self and entire upstream chain.
Only walks ops with a dirty MWKR flag. Recursive memoised formula:
mwkr(op) = duration × (1 − progress) + max(mwkr(downstream))
Uses predicted duration, not actual — result is deterministic across modifier changes.
The JIT-pull loop. Gathers FOpWaitingForMachineTag ops; stable-sorts by (BotPriority ASC, MWKR DESC, OperationHandle ASC) — the final key guarantees determinism. For each op: query eligible machines (verb match, slot free, enabled, modifier > 0); pick the one where effectiveRemaining + op.PredictedDuration is minimum; tiebreak by proximity, then MachineHandle ASC. Write AssignedMachine + SlotIndex, occupy slot, flip tag. Single-threaded by design — the global sort/assign is inherently sequential.
Parallel chunked query over FOpInProgressTag. For each op: look up the assigned machine's modifier; accumulate Progress += DeltaTime × BaseSpeed × MachineMod × ProcessMod / PredictedDuration. Sets FHorizonRepDirtyTag on the machine when slot state changes visibly. No cross-entity writes → safe to parallelise.
Queries FOpInProgressTag with Progress ≥ 1.0. Registers output parts via UPartRegistrySubsystem::CreatePart(slot owner). Flips to FOpSenderAwaitingExportTag. Slot stays occupied — it frees only when the bot picks up the output.
Bridges to the existing ULogisticsSimulationSubsystem: emits delivery orders for SenderAwaitingExport → ReceiverAwaitingDelivery pairs. On bot dropoff callback, transfers part ownership to the receiving op's slot and frees the sender slot.
♻️ Destruction & Recovery
Machine destroyed
MachineLifecycleProcessor implements a pre-removal hook and runs synchronously before the entity is removed:
- 1Read world position from
FMachineActorFragment::Actor->GetActorLocation()before teardown — the actor may already be marked pending-kill. - 2For each occupied slot: call
PartRegistry.DropToFloor(handle, worldPos)for any slot-owned parts. ResetInProgress/Queuedops toWaitingForMachine(clear assignment, reset Progress, mark MWKR dirty). LeaveSenderAwaitingExportops as-is — the part is now on the floor;LogisticsHandoffProcessorre-dispatches pickup on the next tick. - 3Set
FHorizonRepDirtyTagfor a final replication push before entity removal so clients see slots clear.
AMachineActor GC is independent. The TWeakObjectPtr in FMachineActorFragment goes null safely; no processor reads it after entity removal.Bot destroyed mid-transit
ULogisticsSimulationSubsystem fires OnBotDestroyed(FMassEntityHandle, FVector LastPosition). LogisticsHandoffProcessor subscribes and for each part the bot was carrying:
- 1Call
PartRegistry.DropToFloor(handle, lastPosition). - 2Leave the receiving op in
FOpReceiverAwaitingDeliveryTag— it is still waiting for the same part, just needs a new carrier. - 3On the next tick,
GetOrphanedFloorParts()surfaces the handle;LogisticsHandoffProcessorre-emits a pickup bot order (floor position → original destination slot) and callsAssignFloorPartToBotOrder. - 4Cancel the old in-flight order record.
No op tag change is needed — FOpReceiverAwaitingDeliveryTag already means "waiting for this part to arrive"; the new bot order satisfies it identically.
🎨 Actor Representation Layer
AMachineActor is a thin AActor created once per machine. It owns: scene root, mesh, ISM handles, click collision, and a FMassEntityHandle MyEntity back-ref set during spawn.
Runs on client (and server-listen-host). Reads FMachineSlotsFragment and FMachineProgressFragment; drives arm-pose interpolation. Replaces every per-arm IdleTick, JobRangeCheck, and MoveToTargetBehaviour call from the old ARobotArmBase.
The old ARobotArmBase state enum, timers, and behaviour functions collapse to a pure function of (slot state, progress, target-part position) — pose-only, no decisions. All decision logic moves into processors.
📡 Replication
Reuses the existing Plugins/Horizon/ patterns from the logistics bot implementation.
| What | How |
|---|---|
| Machine state | UMachineEntityProxy : UHorizonEntityProxy — UPROPERTY-replicated mirror of FMachineRepStateFragment (slot occupancy, progress, working flag). Marked dirty by server processors via FHorizonRepDirtyTag. |
| Client update | Client MachineRepresentationProcessor consumes proxy data → updates client fragments → drives visuals. |
| Operations | Not replicated. Clients reconstruct visible work state from machine fragments only. |
| Interest filtering | HorizonSpatialRepSyncProcessor provides scale-appropriate interest management per the existing logistics pattern. |
🚀 Cutover Plan
Eight steps from skeleton to full cutover. Both modules compile in parallel throughout. No incremental migration — the old module is deleted in a single PR at step 8.
- 1Stand up
FactoryFloor2/module skeleton — traits and empty processors. Builds alongsideFactoryFloor/. - 2Implement
UPartRegistrySubsystem+ audit processor with unit-like dev tests. - 3Implement Operation entity + full scheduler chain. Validate with a synthetic harness (no actors) — determinism gate and audit processor at every tick.
- 4Implement Machine entity +
AMachineActor+ representation processor. Port one machine type end-to-end — recommendFabricator(cleanest single-slot work loop). - 5Port remaining machine types (RobotArm variants, ItemBin, ItemSpawner, Monorail). Flag: Monorail conveyors may need a dedicated movement processor — treat as a sub-design when reached.
- 6Bridge
ULogisticsSimulationSubsystemcallbacks to the newLogisticsHandoffProcessor. - 7Migrate placement/grid responsibilities from
AFactoryFloorManagerintoUFactoryFloorSubsystem. Drop the work-dispatch managers entirely. - 8Replace all spawn sites producing old
AMachineBasewithUMachineSpawner. DeleteFactoryFloor/in the same PR.
📎 Critical Reference Files
These existing files are read-only references — pattern sources, not files to modify during FactoryFloor2 development.
| File | Why it matters |
|---|---|
| Mass/Logistics/LogisticsBotFragments.h | Fragment design template for the new machine and operation fragments |
| Mass/Logistics/Traits/LogisticsBotServerTrait.cpp | Trait pattern to follow for MachineServerTrait |
| Mass/Logistics/Processors/Server/LogisticsBotStateProcessor.cpp | State-tag processor pattern; this is what the scheduler chain processors should look like |
| Plugins/Horizon/…/HorizonEntityProxy.h | Base class for UMachineEntityProxy |
| Plugins/Horizon/…/HorizonSpatialRepSyncProcessor.h | Interest filtering at scale |
| FactoryFloor/DataTypes/MachineData.h | Port the shape of this into the new MachineData.h |
| FactoryFloor/DataTypes/WorkOrderData.h | Informs the Operation and BotOrder data model |
✅ Verification
Synthetic harness (no rendering): seed N=1024 machines, 4096 ops; run two identical runs; assert byte-equal slot assignment sequences. No actors required — validates the scheduler chain in isolation.
Enable PartRegistryAuditProcessor at every tick in tests. Run a 60s simulated session with machine and bot churn. Zero orphaned parts expected.
ProfileGPU + Unreal Insights: full scheduler chain ≤ 2ms server frame at target scale (1k machines, 10k ops). Verify parallel scaling of MachineWorkProgressProcessor with thread count.
Reuse the ReceiveImGuiDrawcall_Implementation pattern from AMachineBase. Live view: op states per machine, slot occupancy, MWKR values, registry size, orphan count.
4-client soak: clients must show correct slot/progress visuals at 10Hz cadence; no actor desync across a sustained machine churn session.
Load an existing save (flag as a sub-task if save-compat is required) and confirm all machines respawn correctly under the new module. No orphaned parts on load.