Skip to content

Architecture

denfry edited this page Oct 13, 2026 · 1 revision

Architecture

Note

Current state: milestone M0 (scaffold). The Gradle modules, build, CI, server config spec, API holder and architecture tests exist. The simulation itself (SimulationWorld, physics, sensors, networking) is design only. Sections below mark what is implemented and what is planned. See Roadmap.

Prompt Critical separates a pure-Java simulation domain from the Minecraft binding. The simulation can be unit-tested and benchmarked without starting the game, and Minecraft code cannot leak into physics.

Key decisions

# Decision Alternative Why
A1 Simulation is a separate module pc-sim, pure Java 21, no Minecraft dependencies Simulation classes inside the mod Testable with plain JUnit, benchmarkable with JMH
A2 One SimulationWorld per ServerLevel (dimension) One per server Stations never span dimensions; load/save isolated per dimension SavedData
A3 FAST domain on the server thread; SLOW/STORAGE and the radiation field on worker threads with deterministic merge at a tick boundary Everything on a separate thread Exact coupling with Create kinetics; determinism
A4 Structure-of-arrays stores on primitive arrays Object graph Zero allocations in the hot loop, cache locality
A5 SimSnapshot that readers see as immutable, from a buffer pool with optimistic (seqlock) validation Locks / copy every tick Lock-free reads from CC and network threads
A6 Every external action is a SimCommand; every consequence is a SimEvent Direct calls Deterministic ordering, journaling, replay
A7 Clients receive only quantized deltas of subscribed channels Full block entity sync 2 KB/s per player budget

Modules

          +----------+
          |  pc-mod  |--> NeoForge, Minecraft, Create, CC: Tweaked (optional)
          +----+-----+
               | depends
       +-------+-------+
       v               v
  +---------+     +---------+
  | pc-api  |---->| pc-sim  |--> java.base only
  +---------+     +---------+
Module Plugin Role Today
pc-sim java-library + JMH (me.champeau.jmh) Simulation domain. No runtime dependencies; test deps only (JUnit 5, jqwik, ArchUnit, AssertJ) sim.core.SimClock, sim.core.TimeDomain, architecture tests, SimClockTest
pc-api java-library (api(project(":pc-sim"))) Stable interfaces for addons PromptCriticalApi (apiVersion(), CURRENT_API_VERSION = 1), ApiHolder
pc-mod ModDevGradle (net.neoforged.moddev) NeoForge binding: blocks, client, network, integrations @Mod class PromptCritical, ServerConfig, ServerMode, DecayHeatClock, bootstrap GameTests, ~270 placeholder block models and textures

pc-sim and pc-api are packed into the mod jar as classes, not jar-in-jar: one mod, one classloader, no version conflicts. In dev runs both source sets are registered as part of the mod. The design also plans to publish pc-api separately to Maven as a compileOnly artifact for addon developers.

Mod metadata (neoforge.mods.toml): neoforge and minecraft required, create required (ordering AFTER), computercraft optional (ordering AFTER). Version ranges are filled from gradle/libs.versions.toml at processResources.

Dependency rules (implemented)

Gradle physically keeps Minecraft classes off the pc-sim classpath. ArchUnit (SimArchitectureTest) also guards rules the compiler cannot check:

Rule Forbids in ..sim..
noMinecraft Dependencies on net.minecraft.., com.mojang.., net.neoforged.., dan200.computercraft.., com.simibubi..
noUnseededRandom Math.random() and new java.util.Random(); only seeded SplittableRandom
noWallClock System.currentTimeMillis() / nanoTime() outside sim.core.profile
threadsOnlyInExec new Thread(...) outside sim.core.exec
layers Physics (sim.neutronics, sim.thermal, sim.thermo, sim.hydraulics, sim.reactor, sim.nuclide) may only access sim.core, and may only be accessed by systems and core

SimArchitectureRulesTripTest (tag T-05-01) proves the rules actually fire, using deliberate violators in sim.archfixture (LeakyMinecraftUser, UnseededRandomUser, WallClockUser) and a fake net.minecraft.core.BlockPos in the test tree. The layers are declared optional for now, since the physics and systems packages are still empty.

Planned packages

Package Module Contents
sim.core pc-sim SimulationWorld, SimClock, TimeDomain, SimId, SimCommand, SimEvent, SimSnapshot, CommandQueue, EventBuffer, state writer/reader, migrations
sim.core.exec, sim.core.profile pc-sim Worker pool; profiler and JFR events
sim.reactor, sim.neutronics, sim.thermal, sim.thermo, sim.hydraulics, sim.nuclide pc-sim Physics
sim.protection pc-sim ProtectionSystem, TripReason, TripSource
sim.instrument, sim.alarm, sim.acoustics pc-sim Sensors, actuators, tags, DeviceDescriptor; AlarmEngine; AcousticField
sim.radiation, sim.accident, sim.consequence, sim.journal pc-sim Radiation field, accident models, damage budget, event journal
sim.chem, sim.enrichment, sim.mechanics, sim.automation pc-sim Chemistry, cascades, rotating machines, PLC/SIS
api pc-api PromptCriticalApi, registration events, SimulationAccess
mod.registry, mod.binding, mod.block, mod.blockentity, mod.item pc-mod Registries, BE ↔ domain adapters, game objects
mod.net, mod.client pc-mod Payloads and sync services; BERs, screens, sound
mod.compat.cc, mod.compat.create, mod.compat.claims pc-mod CC peripherals; Create kinetics and Ponder; FTB Chunks / OPAC
mod.config, mod.command, mod.data pc-mod Config spec, /pc commands, datagen

Time domains (implemented)

TimeDomain and SimClock are the only real simulation code so far:

Domain Period Physical time per step
FAST every tick (0.05 s) 0.05 s (real time)
SLOW every 20 ticks (1 s real) K_slow s (time.k_slow, default 72)
STORAGE every 1200 ticks (1 min real) K_storage × 60 s (storage.k_storage, default 8766 ≈ one physical year per real hour)

SimClock keeps a monotonic tick counter (saved with the world) and offers simTime(), slowPhysTime(), slowDtPhys(), storageDtPhys(), slowAlpha() (interpolation between SLOW results for FAST consumers) and due(domain, phase). Phases spread SLOW jobs of different subsystems over different ticks. It is the only time source allowed in sim.

Server config (implemented)

promptcritical-server.toml, built with ModConfigSpec in ServerConfig:

Key Default Range Meaning
time.k_slow 72.0 1–3600 Physical seconds per real second in SLOW
storage.k_storage 8766.0 1–10⁶ Physical seconds per real second in STORAGE
nuclide.k_burn 4.0 1–100 Burnup acceleration
nuclide.decay_heat_clock SPLIT SPLIT / REAL_TIME Slow decay-heat groups on K_slow, or all real-time (hardcore)
energy.w_per_su 1000.0 1–10⁶ Watts of shaft power per Create stress unit
energy.j_per_fe 2.5 0.01–10⁶ Joules per Forge Energy unit
cc.token_rate 400 10–100 000 CC peripheral call tokens per second
cc.token_burst 800 10–200 000 CC token bucket capacity
server.mode CONTAINED REALISTIC / CONTAINED / CREATIVE_SAFE Consequence policy
damage.max_blocks_per_tick 64 0–4096 World block changes per tick from accidents

Planned: the FAST step

A fixed order is part of the determinism contract:

# Stage Subsystem
1 Apply commands CommandQueue.drainSorted()
2 Actuators Rod and valve travel; scram rod release (flag from stage 10 of the previous tick)
3 Neutron kinetics sim.neutronics
4 Heat structures sim.thermal
5 Hydraulics sim.hydraulics
6 Machines Pumps, turbines, cascades (coupled to Create shaft speed)
7 Accident models Cladding, H₂, containment
8 Acoustics sim.acoustics
9 Sensors Measurements from the state after stages 3–7 (PLC/SIS stages 9a–9c are inserted here)
10 Protection Scram decision, applied at stage 2 of the next tick
11 Alarms sim.alarm
12 Publish SnapshotPublisher
13 Events EventBuffer → binding

The one-tick (50 ms) scram delay is physically justified: real logic and gripper release take 0.1–0.3 s.

Planned: determinism

Same initial state + command log + external inputs ⇒ bit-identical state after N ticks. This enables replay, tests and multiplayer fairness.

Non-determinism source Measure
HashMap iteration order Forbidden in the hot loop; arrays sorted by SimId
Floating point Strict IEEE 754 by default since Java 17 (JEP 306)
Math.sin/exp/log/pow intrinsics SimMath facade over StrictMath (fdlibm)
Randomness SplittableRandom seeded from mix(worldSeed, simId, streamId) (ArchUnit-enforced)
Parallel SLOW jobs Merged at a fixed tick regardless of completion time
Command order across threads Sorted on drain
Wall clock Forbidden in sim except the profiler (ArchUnit-enforced)

SimulationWorld.stateHash() (xxHash64 over all SoA arrays) is exposed as /pc debug hash and used in tests.

Planned: threading model

Three options were compared: everything on the server thread, a separate pipelined simulation thread, and a hybrid (chosen). A pipelined thread would always see the world one tick late, while block changes and Create kinetics happen on the server thread inside the tick, a source of subtle desyncs. FAST physics is cheap; expensive work is slow by nature and easy to offload.

Tick protocol:

ServerTickEvent.Pre (server thread)                         workers (perf.worker_threads)
 1. TopologyRebuilder.process(DirtySet)       (<= 300 us)
 2. ExternalInputs.capture()                  (Create kinetics, chunk loading)
 3. for each subsystem due(SLOW, phase):
      a) join job started 20 ticks ago  --- wait <= perf.slow_join_timeout_ms ---+
      b) mergeSlow(result)                                                       |
      c) submit next job with a copy of inputs  ---------------------------->  run SLOW job
 4. same for STORAGE (period 1200)
 5. SimulationWorld.tick(): FAST stages
 6. RadiationField: merge finished ray batches, start the next  ----------->  ray tracing
ServerTickEvent.Post
 7. DamageBudget.drain()                      (<= 64 blocks)
 8. CcEventPump: SimEvent -> queueEvent
 9. Journal, chat announcements
10. InstrumentSyncService / AcousticSyncService: encode and send
  • A SLOW job started on tick t is always merged on tick t + 20, even if it finished earlier, so the state does not depend on worker speed.
  • If it is not ready, the server thread waits up to perf.slow_join_timeout_ms = 5 ms and then steals the remaining work. A SLOW step is never skipped.
  • perf.worker_threads defaults to max(1, min(2, N_cpu − 2)).
  • CommandQueue is a bounded MPSC array queue (capacity 4096). Producers are CC threads, the network thread and the server thread. Overflow returns E_RATE to Lua. On drain, commands are sorted by source class (automation/watchdog/policy/admin = 0, desk = 1, Lua = 2), then source id, then sequence; at most 512 per tick.

Planned: binding (pc-mod)

Every block entity with a domain twin implements DeviceHost (simId(), level(), pos(), mode(), lazy peripheral()). Lifecycle:

Minecraft event Binding action
BlockEntity.onLoad() (server) SimLevelBinding.attach; register if simId is empty
Block placed Mark the neighbourhood dirty for incremental topology rebuild
Block broken / exploded detach + mark dirty; device removed at the start of the next tick
Chunk unload / load Station load state machine (freeze / thaw)
Level unload / server stopping Save, close pools
ServerTickEvent.Pre Tick protocol above

Planned: network sync

Principle: send the client only what the player sees or hears.

Data Recipients Rate
Desk gauges, displays, lamps, annunciators, mimics Players within net.instrument_radius = 24 m Gauges 10 Hz, digital displays 2 Hz, lamps on event
Chart recorder paper Same 1 point/s, batches every 5 s
Open GUIs (vibrometer, block screens) The opener 1 Hz
Sound emitters Within 48 m 4 Hz deltas
Player dose and contamination Self 1 Hz
CC monitors — Synced by CC itself

Budget: 2 KB/s per player, split into token buckets net.budget.instruments = 1400, net.budget.acoustics = 400, net.budget.player = 100 B/s plus reserve. A typical control room (40 gauges, 10 displays, 4 recorders, 2 annunciators, 20 mimic elements, 20 sound emitters) needs about 1180 B/s, worst case ≈ 2050 B/s. When the instrument bucket runs dry, far gauges (> 12 m) fall back to 5 Hz and then 2 Hz. Lamps and alarms are always sent.

Testing

Level Tool Location Status
Unit JUnit 5 + AssertJ pc-sim/src/test SimClockTest exists
Architecture ArchUnit pc-sim/src/test Implemented (rules above)
Properties jqwik pc-sim/src/test Engine wired; 1000 tries per commit, 10⁵ nightly planned
Physics validation JUnit + reference tables (IAPWS-IF97, analytic kinetics) pc-sim/src/test/.../validation Planned
Determinism JUnit replay pc-sim/src/test Planned
Concurrency jcstress pc-sim/src/jcstress Planned (snapshot seqlock, command queue)
Integration NeoForge GameTest pc-mod/src/gametest BootstrapGameTests: API installed, server config loaded with defaults, Create loaded
Performance JMH pc-sim/src/jmh Plugin applied; benchmarks planned

Traceability (planned): every test-case ID in the GDD tables (T-3b-01, T-4a-17, ...) must have a test annotated @Tag("..."). A meta-test GddTraceabilityTest will parse the design document tables and fail if an ID has no test. The existing architecture trip test already carries @Tag("T-05-01").

JMH targets on the reference machine (4 cores, ~3.5 GHz, JDK 21): point kinetics step ≤ 5 µs; one reference core ≤ 150 µs; fluid network of 400 nodes ≤ 500 µs; 400 sensor channels ≤ 10 µs; 500 alarms ≤ 15 µs; 200 acoustic emitters ≤ 10 µs; full tick with 4 reactors and 20 players p95 ≤ 2.0 ms; 0 bytes allocated per FAST operation.

Extensibility

Implemented: PromptCriticalApi.get() returns the instance that the mod installs into ApiHolder during construction. It currently exposes only apiVersion(). ApiHolder.install throws if called twice or with null; get() throws if the API is not initialized yet.

Planned:

  • SimulationAccess simulation(dimension): read-only access for other mods, through the same sensor model as Lua (read(stationId, tag), tags(stationId), subscribeEvents(listener)).
  • Registration events on the mod bus: RegisterProcessUnitsEvent, RegisterInstrumentsEvent, RegisterPeripheralExtensionsEvent (cost and call class mandatory), RegisterAccidentHandlersEvent.
  • Materials, nuclides and fuel come from datapacks, not Java.
  • ROM Lua (pcui, pcctl, pctest, examples) ships through the mod's data/computercraft/lua/rom/... datapack.

See also

Clone this wiki locally