Skip to content

Latest commit

 

History

History
982 lines (820 loc) · 66.6 KB

ARCHITECTURE.md

File metadata and controls

982 lines (820 loc) · 66.6 KB

Architecture

This document explains how MCHeli Rework is organized and why. It reflects the code as of 1.1.0.

Goals

  1. Keep the content. The 130 vehicles, 229 weapons and their models are the value of MCHeli. They load unchanged, and so do content packs written for the original.
  2. Rewrite the engine. 1.7.10 APIs are gone, so the code is not translated line by line. Behavior is ported where players can feel it (flight model, defaults, ranges) and redesigned where the platform changed (rendering, networking, registration).
  3. Keep the core testable. Parsing and model loading do not depend on Minecraft, so they run in plain unit tests against every shipped file.

Layers

 ┌────────────────────────────────────────────────────────────────┐
 │ forge / neoforge / fabric   entry point, event wiring, packet  │  loader module
 │                 channel, vehicle ingredient type, mixins       │  (forge-26.3, ...)
 ├────────────────────────────────────────────────────────────────┤
 │ client          renderer, mesh cache, runtime assets, crew     │  client only
 │                 controller, sound, HUD renderer, pack download │
 ├────────────────────────────────────────────────────────────────┤  minecraft-<version>
 │ entity          AircraftEntity and one subclass per kind       │  (minecraft-26.3)
 │ item            AircraftItem, VehicleIngredient                │
 │ network         packets, ModNetwork, PackSync                  │  Minecraft, no loader
 │ particle        GlowParticleOptions                            │
 │ registry        entities, items, components, particles, tabs   │
 │ platform        Platform, Registration                         │
 │ (root)          MCHeli, CommonHooks, configs, McheliCommand    │
 ├────────────────────────────────────────────────────────────────┤
 │ content         Definition, DefinitionReader, ContentRegistry, │  common module
 │                 aircraft/* definitions, recipe conversion,     │
 │                 pack/* content packs                           │
 │ model           Mesh, MqoLoader, ObjLoader                     │  pure Java
 │ hud             HudScript, Expression, HudVariable             │
 │ physics         AircraftRotation, GroundImpact                 │
 │ workshop        WorkshopProject, DefinitionDocument, PartType  │
 │ effect          NightGlow                                      │
 │ config          ConfigSpec (the TOML config format)            │
 └────────────────────────────────────────────────────────────────┘

Arrows only point down. The bottom layer lives in the common Gradle module, which is compiled and tested with nothing from Minecraft or a loader on its classpath (only the JDK, SLF4J, JSpecify and JOML), so a stray import fails the build; CommonBoundaryTest also scans the sources. The middle layer, minecraft-<version>, is everything that touches Minecraft but no loader. The top layer is a thin module per loader and Minecraft version, which compiles both lower layers together with its own glue into one jar. See DEVELOPMENT.md.

Only 26.3, 26.2, 26.1, 1.21.11, 1.21.5, 1.21.1 and 1.20.6 have the middle layer so far. The other supported versions have placeholder loader modules (scaffold=true) that compile neither lower layer: just an entry point that logs, against that version's loader and toolchain. Porting a version means adding its minecraft-<version> folder, copying the loader glue in and dropping the flag.

The shared code reaches the loader in two ways only:

  • Platform, installed by the loader module before anything else runs: the game and config folders, the mod version, whether this is a client, sending a packet, and a creative tab builder (vanilla's is not public on Fabric, and only Forge and NeoForge order tabs).
  • Registration, a list of registry entries (Registered<T>, like Forge's RegistryObject) that the shared registry classes declare during MCHeli.init. Forge and NeoForge register them from their RegisterEvent; Fabric registers them straight away.

Everything else goes the other way: the loader module calls CommonHooks (server start and stop, ticks, player join and leave, commands, the imported pack) and client.ClientHooks (renderers, keys, reload listeners, HUD layer, ticks, camera, hand, use and pick block keys, rider pose) from its own events. Where a loader has no event, the loader module adds a mixin at the place Forge fires its event, so the shared code sees the same thing on every loader:

Hook Forge NeoForge Fabric
Camera angles and cockpit eye ViewportEvent.ComputeCameraAngles mixin on GameRenderer (NeoForge's event fires before vanilla sets the camera position) mixin on GameRenderer, plus roll on Camera
Hide the hand RenderHandEvent RenderHandEvent mixin on GameRenderer.renderItemInHand
Use and pick block keys InputEvent.InteractionKeyMappingTriggered the same mixin on Minecraft
Rider tilt RenderLivingEvent.Pre RenderLivingEvent.Pre mixin on LivingEntityRenderer.submit
Imported content pack AddPackFindersEvent AddPackFindersEvent mixin on the PackRepository constructor
Streamed sounds SoundInstance.getStream up to 1.21.5; PackSoundBuffers from 1.21.11 SoundInstance.getStream mixin adding getAudioStream to RuntimeSoundInstance

NeoForge rolls the camera the other way round from Forge, so its mixin negates the roll.

Forge 1.21.11 and later still declare SoundInstance.getStream, but their SoundEngine calls SoundBufferLibrary.getStream directly, and a failed stream leaves no trace in the log. So forge-<version> swaps the library for its own subclass on SoundEngineLoadEvent, through an access transformer on the field. The subclass only steps in for an mcheli sound no resource pack has.

Minecraft 26.2

minecraft-26.2 is minecraft-26.3 with these differences:

  • Rendering API. Pipelines live in com.mojang.blaze3d rather than com.mojang.renderpearl, there are no order independent transparency pipelines to name, and PoseStack turns through mulPose. Glow spells out vanilla's eyes pipeline, which has no snippet to build on yet, and ProjectileRenderer makes its own translucent culled render type, since vanilla only offers one drawn into the item target.
  • Interpolation. Entity does not own or run a handler. Each of our entities holds an InterpolationHandler, returns it from getInterpolation and calls interpolate after super.tick(), the way vanilla's boat does.
  • Thermal view. There is one post effect slot on GameRenderer rather than a list on the player, and its setter is private, so each loader hands ClientHooks.init a way to call it: an access transformer on Forge, a mixin invoker on NeoForge and Fabric. The shader has no explicit attribute locations, matching vanilla's on this version.
  • Input on Forge. InteractionKeyMappingTriggered is one event with isUseItem and isPickBlock, not an event per key.
  • Rider pose. CameraRenderState does not carry the camera entity's partial tick, so Forge and Fabric ask ClientHooks.cameraPartialTicks. NeoForge's event has it.

Minecraft 26.1

minecraft-26.1 is minecraft-26.2 with these differences:

  • Depth. 26.1 is the last version with a normal depth buffer, so the glow pipelines test with LESS_THAN_OR_EQUAL. The enum exists on both sides of the change, so the wrong one compiles and only shows as glows drawn through walls.
  • Rendering API. Pipelines take samplers and a vertex format through withSampler and withVertexFormat rather than bind groups and a primitive topology.
  • Screens. They live on Minecraft, not Gui, as they do on 1.21.1.
  • Teams. There is no TeamColor. The team manager picks from the ChatFormatting colours.
  • Block entity types. The constructor is private in vanilla, so Platform.blockEntityType makes them: Forge and NeoForge have opened the constructor, Fabric API has a builder.
  • Camera hooks. GameRenderer.update also takes whether game time advances, and Fabric's camera mixin shadows Vector3f constants rather than Vector3fc.

Minecraft 1.21.11

minecraft-1.21.11 is minecraft-26.1 with these differences. It is obfuscated in production, so Forge sets the official mappings and Fabric uses the remapping Loom plugin.

  • Cutout render types. entityCutout culls here and does not on 26.x, and entityCutoutNoCull is what 26.x calls entityCutout. The names exist on both sides, so a straight copy compiles and turns parts inside out.
  • Screens. render, renderBg and renderLabels rather than the extract methods, on GuiGraphics with drawString rather than GuiGraphicsExtractor with text. Container screens set their size after super.
  • Pipelines. withBlend and withDepthWrite rather than colour target and depth stencil states.
  • Interaction. A click on the hull arrives through interactAt, as on 1.21.1.
  • Camera. GameRenderer.updateCamera calls Camera.setup, and NeoForge and Fabric hook after that and at the head of extractCamera. Field of view is GameRenderer.getFov.
  • Pack metadata. One pack.mcmeta is read both as client resources and as server data, and each has its own limit: resources refuse supported_formats above format 64, data requires it up to 81. So a range can only start at 82, above the game's own resource format of 75. All three loaders still load the mod's resources, since mod packs are not refused for their format.
  • Fabric API. The older names: KeyBindingHelper, ParticleFactoryRegistry, FabricItemGroup, PayloadTypeRegistry.playS2C, and on CustomIngredient getMatchingItems and toDisplay.
  • Conventional tags. Forge on 1.21.x has forge:gunpowder and forge:feathers but not the c: names our recipes use, so its modules define c:gunpowders and c:feathers from those.

Minecraft 1.21.5

minecraft-1.21.5 takes entities, items, recipes and networking from minecraft-1.21.11, and its screens' input handling and the workshop from minecraft-1.21.1. What differs:

  • Rendering. There are render states but no submit step. render draws straight into a MultiBufferSource, and Geometry.submit keeps those calls reading the same as elsewhere.
  • Save data. CompoundTag has store, read and getIntOr, so it stands in for ValueOutput and ValueInput. Anything holding items needs the registries passed along.
  • Interpolation. A packet with only a rotation interpolates to where the entity is now, dropping the target, so VehicleInterpolation keeps the target in that case.
  • Thermal view. Post effects use plain uniforms: InSize rather than a SamplerInfo block, and a uniforms list in the JSON.
  • blit. It takes a render type function, then position, texels and size in that order, so a call from 1.21.1 with its size first still compiles and draws the wrong region.
  • Glow. Our render types come from a nested subclass, the only place the shards they need can be seen, and vanilla's itemEntityTranslucentCull draws translucent rounds.
  • Hull outline. The render step has no entity, so the extract step copies the boxes into the state.
  • Teams. There is no PVP game rule, so the team manager switches the server's setting, as on 1.21.1.

Minecraft 1.21.1

minecraft-1.21.1 is a copy of minecraft-26.3 ported back, so the two read side by side. During the betas it is not kept in step; each final release brings it, and every other supported version, up to date again. What differs:

  • Rendering. 1.21.1 has no render states or submit step. Each renderer fills the same state object itself at the start of render and draws straight into the MultiBufferSource, so PartRenderer, MeshRenderer and GlassSplit are unchanged apart from that. The glow uses a render type built from RenderType's public constructor (vanilla keeps RenderType.create package private) and a custom ParticleRenderType for additive particles.
  • Vehicle icons. There is no item_model component. The import writes one item model per kind whose custom_model_data overrides pick each vehicle's model, and lists the numbers in mcheli/imported/item_icons.properties; AircraftItem.stackOf puts the number on the stack.
  • Recipes take ingredients as {"item": ...} and {"tag": ...} objects; the import writes them that way for ImportTarget.V1_21_1. The marker file records the target, so a folder imported for one version is imported again by the other.
  • Entities extend Entity instead of VehicleEntity, whose abstract getDropItem is package private in vanilla, and keep their own hurt wobble data. Save data is CompoundTag, damage goes through hurt (server only), server position updates are eased in by lerpTo like vanilla's boat, and a click on the hull arrives through interactAt.
  • Camera. Forge 1.21.1 fires its camera event after the camera is placed, so Forge uses the event. NeoForge and Fabric hook GameRenderer.renderLevel just before the camera rotation turns into the view matrix, the spot Forge fires from.
  • Drafting table. There is no recipe display system. The client holds every recipe, so the screen reads its RecipeManager and DraftingTablePacket sends the recipe's id.
  • Thermal view. Post effects are the older shaders/post chains, and vanilla loads programs from the minecraft namespace only, so ours is assets/minecraft/shaders/program/mcheli_thermal. GameRenderer.loadEffect is private, so each loader hands ClientHooks.init a way to call it.
  • Particles. Smoke is a custom ParticleRenderType too. Vanilla only draws the types on its own list, so ParticleEngineMixin adds ours on Fabric; Forge and NeoForge draw every type in use.
  • Workshop preview. With no GUI entity renderer, WorkshopScreen draws through AircraftRenderer.draw the way vanilla draws a mob in the inventory, then clears the depth buffer so the gizmos stay on top.
  • Shaders. There is no IrisCompat: Iris's pipeline assignment only exists for 26.x.
  • Teams. There is no PVP game rule or TeamColor. The team manager switches the server's PVP setting, which lasts until a restart, and picks from the ChatFormatting colours.

Minecraft 1.20.6

minecraft-1.20.6 is minecraft-1.21.1 with these differences:

  • Data folders. They still have plural names (recipes, loot_tables, tags/items), so ImportTarget.V1_20_6 writes recipes to data/mcheli/recipes, and the jar guard in gradle/loader.gradle keeps EMB4's out of both spellings.
  • Vertices. The older builder: vertex, color, uv, overlayCoords, uv2, normal, and an endVertex to finish each one.
  • Particle render types. begin is handed a builder and end a tesselator.
  • Camera. The view matrix comes from the camera's angles rather than its quaternion, and each loader gets the roll in differently. NeoForge's ComputeCameraAngles stacks it on top of the view with the opposite sign, so its glue negates it and needs no camera mixin. Fabric hooks after resetProjectionMatrix and wraps Matrix4f.rotationXYZ to do the same. Forge passes the event's roll into rotationXYZ itself, where it turns around the world's Z axis, so ForgeClient replaces all three angles with AircraftRotation.rollInXyzOrder, which builds the same matrix.
  • Conventional tags. Forge has none of the c: tags, so forge-1.20.6 defines all our recipes use, from the vanilla items and any matching forge: tag. Fabric lacks five, defined in fabric-1.20.6.
  • Respawn. TeamManager finds the respawn point itself, since there is no DimensionTransition.

client is only referenced from the loader modules behind a physical client check, so a dedicated server never loads a client class.

Package root: com.jnhprojects.mcheli, with the loader glue in .forge, .neoforge and .fabric. Mod id and asset namespace: mcheli, the same as the original, so existing resource paths and content packs keep working.

Startup

loader entry point (McheliForge, McheliNeoForge, McheliFabric)
  ├─ Platform.install
  ├─ MCHeli.init
  │    ├─ LegacyContent.prepare                       import the original zip into mcheli/imported
  │    ├─ ContentPacks.load(false)                    the import plus every pack in mcheli/packs
  │    ├─ ModDataComponents.init                      mcheli:vehicle
  │    ├─ ModEntities.init                            one entity type per kind
  │    ├─ ModParticles.init                           mcheli:glow
  │    ├─ ModItems.init(content)                      one item per kind, plus the 1.0.0 items
  │    └─ ModCreativeTabs.init
  ├─ register Registration's entries, and the mcheli:vehicle ingredient type
  ├─ register ModNetwork's packets
  ├─ wire events to CommonHooks                       imported pack, server config, packs, commands
  └─ client only: ClientHooks.init and client events  client config, renderers, keys, HUD, input

Definitions are still parsed during mod construction, but only because the 1.0.0 items have to be registered before the loader freezes the registries (see Vehicle items); nothing else depends on them existing that early any more. The files are read straight from the imported folder and the content packs (see Assets and Content packs). Models and textures, by contrast, go through the resource manager, so resource packs can replace them.

Content definitions

DefinitionReader implements the line format: key = value, case insensitive keys, ;, # and // comments, repeatable keys. Each Definition subclass registers one handler per key in registerKeys, which keeps the ported logic close to the original loadItemData branches without the 1,700 line if/else chain:

keys.put("MaxHp", v -> maxHp = toInt(v, 1, 100000));
keys.putAliased("AddGunnerSeat", this::parseSeat);

Defaults and clamping ranges are copied from MCH_AircraftInfo and its subclasses. After all lines are applied, validate() fills in derived values (default HUDs, camera, UAV seat) and can reject the definition.

Deliberate differences from the original loader:

Original Rework Why
One bad line discarded the whole file The line is skipped and reported Easier for content authors
Unknown keys were silently ignored Unknown keys are collected and logged at debug Shows which keys still need porting
Read as UTF-8 only, mangling Shift-JIS UTF-8 with a Windows-31J fallback 15 shipped files are Shift-JIS

ContentRegistry holds the loaded definitions, and can change while the game runs: add replaces or adds one definition, remove drops one, and replaceWith swaps in a whole freshly loaded set. The maps sit in an immutable snapshot that writers copy and publish in one volatile write, so readers never lock and the render and server threads of a single player game always see a consistent set. Every change bumps generation(). AircraftEntity.definition() caches its definition together with the generation it was looked up at, and looks it up again when the number moves, rebuilding seats, parts and weapons while keeping the ammunition it carries. MCHeli.content() is always the same object; reloads change what it holds.

Vehicle items

1.0.0 registered one item per vehicle at startup, and the loaders freeze the item registry before a world loads, so a vehicle added later could never get an item. Since 1.1.0 there is one item per kind (mcheli:helicopter, mcheli:plane, mcheli:tank, mcheli:vehicle). A stack names its vehicle in the mcheli:vehicle data component and points its minecraft:item_model component at the item model the import wrote for that vehicle, so it looks and reads as before. AircraftItem.stackOf(def) is the one place that builds such a stack (creative tabs, /mcheli give, pick block, packing a vehicle up).

Existing worlds keep their items through a migration rather than a data fixer: the 1.0.0 ids (mcheli:ah-64 and so on) are still registered for the content present at startup, as LegacyAircraftItem. They place vehicles as before, are not listed in any tab, and replace themselves with the generic stack (count, name and other components kept) as soon as they tick in a player's inventory. A stack in a chest converts when a player takes it out.

Recipes craft the generic item with both components set. A vehicle used as an ingredient (the AH-6X recipe takes an AH-6) becomes a {"type": "mcheli:vehicle", "vehicle": "ah-6"} ingredient that only matches stacks of that vehicle. Each loader names custom ingredients under its own key (type on Forge, neoforge:ingredient_type on NeoForge, fabric:type on Fabric), so the import writes all three and each loader ignores the other two; a world keeps working when it moves to another loader. VehicleIngredient holds the matching, and each loader module wraps it in its own ingredient class.

Reloading content

/mcheli reload (operators) runs ContentReload: ContentPacks scans the import and the packs again with the server config applied, the result is replaceWithed into the live registry, and every player gets the new PackListPacket. Vehicles in the world pick up their new definition on their next tick. Each client then rebuilds its own registry the way it does on joining (see Content packs), except in single player, where client and integrated server share it. ClientContentReload.refresh then clears the mesh cache and glass splits, uploads every vehicle texture again from disk (RuntimeAssets.refreshTextures) and rebuilds the creative tabs.

Content packs

A content pack (content.pack.ContentPack) is a zip or folder with a pack.properties manifest and an assets/mcheli tree laid out like the import. Opening one walks every file: only definitions, models, textures and sounds are allowed (so a pack can never carry code), file names must be lower case, and the file count and unpacked size are capped while reading, not trusted from the zip. The content hash is SHA-256 over each file's path and its own SHA-256, so it depends on the files rather than on how they were zipped; servers and clients compare it to decide what to send. Zips are read in place through a zip file system and stay open while in use.

ContentRegistry.load takes a list of ContentSources, each a pack id and an assets/mcheli folder. Weapons load first from every source, so vehicles can use weapons from another pack; then each source's vehicles in order, a later definition replacing an earlier one of the same name whatever its kind. Every definition records its pack (Definition.pack()). Problems are collected as PackProblems instead of thrown: a file that cannot be read, a rejected definition, a bad line, an unknown weapon. Those of the import are only logged, as before.

ContentPacks (platform) owns <game dir>/mcheli/packs. The constructor loads the import plus every pack, so the vehicles exist before any world does. ServerAboutToStartEvent, the first moment the per-world server config exists, loads again with the allow list and size limits applied, and so does /mcheli reload. A replaced set of packs is closed one reload later, so the client of a single player game never reads from a zip that was just closed. Only the import's vehicles get a 1.0.0 item and an item_model component; item models only load with resources, so pack vehicles show their kind's generic item.

 server                                              client
 ──────                                              ──────
 player joins
   ── PackListPacket(id, version, hash, size) ────▶ own mcheli/packs or mcheli/cache/<hash>.zip?
   ◀─────────────── PackRequestPacket(hashes) ──── for the ones it has neither
 PackSync, every tick, per player
   ── PackDataPacket(hash, offset, 128 KiB) ──────▶ assembled in memory, size checked
      (2 chunks a tick, one pack after another)      written as <hash>.download.zip, opened,
                                                     hash checked, renamed to <hash>.zip
                                                   all there: ClientPacks.apply
                                                     import + the server's packs, in its order
                                                     replaceWith, RuntimeAssets roots, refresh
                                                     trim the cache to packs.cacheSize

The server only sends packs from its current list, by hash. ClientPacks uses exactly the server's packs while connected, so the vehicle a player simulates has the server's definition; a pack of the player's own counts as cached when its hash matches. The import is never sent. In single player and for a LAN host, the client points RuntimeAssets at the server's open packs instead. RuntimeAssets.setRoots swaps the folder list in one write, the pack loaded last first.

Server config and command

ServerConfig is serverconfig/mcheli-server.toml in each world, read when the world starts and on /mcheli reload: explosions breaking blocks and starting fires, a damage factor for vehicles, infinite ammunition, and a list of disabled vehicles. Only the server applies these rules; the getters return the defaults (the 1.0.0 behaviour) when no world is loaded. McheliCommand registers /mcheli (content status), /mcheli list [kind], and for operators /mcheli give and /mcheli reload.

Both configs use config.ConfigSpec from common, a small TOML reader and writer with the layout Forge's config spec wrote, so files from 1.0.0 and the first betas keep their values on every loader. A missing key or bad value falls back to its default, and the file is written back complete with comments. Unlike Forge's, it does not watch the file: edits apply on the next world start or /mcheli reload.

Recipes are data in 26.x, so the definitions' AddRecipe and AddShapelessRecipe lines are turned into data/mcheli/recipe/<vehicle>.json during the import by content.recipe.LegacyRecipe, following MCH_ItemRecipe. LegacyItems maps the 1.7.10 item names and damage values: dyes and wool by colour, the common c: tags where an item became a family (iron, glass, dyes) so other mods' materials count, vanilla tags for logs, planks and boats. As in 1.7.10, a pattern letter the key does not define is an empty slot. RecipeDataTest converts the whole set and spot checks the output. CommonHooks.playerLoggedIn puts every MCHeli recipe in a player's recipe book on login, which is where the drafting table lists them from. Recipe ingredients may also name the equipment items (LegacyRecipe.EQUIPMENT_ITEMS): the E-767 needs a UAV station.

Models

MqoLoader and ObjLoader produce a Mesh: an ordered list of MeshGroups, each a flat triangle list with positions, normals and UVs in parallel float arrays. The loaders reproduce the original exactly where it shows: MQO coordinates are divided by 100, MQO winding is reversed, quads are split, OBJ V is flipped, and MQO shading/facet produce smoothed normals.

MCHeli marks movable parts by object name. Mesh.part("$blade0") returns the $blade0 object and every following object up to the next $ object, which is the original renderPart rule. Mesh.body() returns $body, or everything when there is no $body.

Models are files rather than baked Minecraft models because they are large (tens of thousands of triangles), animated per part and authored outside Minecraft's JSON format.

Entities

There is one entity type per vehicle kind (mcheli:helicopter, plane, tank, vehicle), not one per vehicle. The entity stores its definition name in synched data, as the original did. Adding content never changes the entity registry, and worlds keep loading when a content pack is removed. An entity whose definition is missing stays inert instead of crashing.

VehicleEntity (vanilla)
  └─ AircraftEntity          definition lookup, HP, seat map, part states, racks, authority, save data
       ├─ HelicopterEntity   rotor animation, flight model, mouse handling
       ├─ PlaneEntity        flight model, VTOL nozzles, wings, mouse handling
       ├─ TankEntity         drive model, TerrainFollower
       └─ GroundVehicleEntity  static mounts, optional CanMove

Seats

Vanilla only knows an ordered passenger list, so AircraftEntity keeps a seat map: one entity id per seat, synced as a string. Crew seats come first (seat zero is the pilot and decides getControllingPassenger), then racks, which is also the index space ExclusionSeat uses. The server assigns a seat when a passenger is added, frees it when the passenger leaves, and moves players between seats without remounting them. Occupants are saved by UUID, so a reloaded world puts everyone back where they sat.

Racks reuse the passenger mechanism: a carried vehicle is a passenger in a rack seat. While it is a passenger it does not simulate, and the carrier positions and orients it every tick.

Moving parts and animation

PartState is a port of MCH_Parts: canopy, landing gear, hatch, VTOL nozzle and wing each own one bit of a synced integer, the same bit layout the original used. Only the server flips a bit (after checking the original's conditions); every side then animates the 0 to 90 degree angle locally, so no angles are sent.

AnimationState holds what is purely visual: wheel roll and steering, track run and rollers, spinning parts and each seat's view direction relative to the hull. It is derived on every client from synced throttle, key bits and the crew's own rotations.

Ground vehicles

TerrainFollower replaces the original's invisible wheel entities. Each SetWheelPos wheel and its mirror image is a one block collision box moved with vanilla's collision code; it is dragged along with the hull, falls onto the terrain and climbs steps up to 1.5 blocks. Each left/right pair spans a plane with the centre of gravity, and the weighted average normal gives the target pitch and roll, limited per tick by OnGroundPitchFactor and OnGroundRollFactor.

Authority and networking

The rework uses the vanilla vehicle model rather than the original's custom packets:

 pilot client                              server                        other clients
 ────────────                              ──────                        ─────────────
 CrewController reads keys + mouse
 <kind>Entity.simulate()
 move(...)
   ── ServerboundMoveVehiclePacket ──▶  validates movement
   ── PilotStatePacket(throttle, ──────▶ acceptPilotState()
         reverse, roll, hover, keys)     synched data ────────────────▶ interpolate pose,
                                         entity tracking ─────────────▶ read throttle/roll/keys
 any crew member
   ── CrewActionPacket(gear, fold, ────▶ checks seat and conditions,
         vtol, eject, seat, racks)       flips part bits / seat map ──▶ animate locally

isLocalInstanceAuthoritative() decides where simulate() runs: on the pilot's client while a player is at the controls, on the server otherwise (a parked or abandoned helicopter still falls and its throttle winds down). Position, yaw and pitch travel in the vanilla packet, which the server already checks for speed and collisions. Roll, throttle, hover and key state have no vanilla field, so PilotStatePacket carries them. The server accepts it only from the controlling passenger of that exact entity, and ignores non-finite values.

The flying client never waits for a server round trip, which is what makes mouse flying feel direct.

Flight model

HelicopterEntity.updateMotion is a port of MCH_EntityHeli.onUpdate_Server:

  • gravity from the definition, separate in water;
  • lift (lift * 0.025 + 0.03) * throttle, where lift falls linearly to zero as pitch plus roll approaches 83 degrees;
  • forward thrust 0.1 * currentSpeed * pitch^3 / 30000 * throttle along the yaw;
  • a speed cap from Speed, with currentSpeed spooling up over about 35 ticks;
  • ground friction and auto levelling.

Rotation input is applied in the aircraft's own frame (AircraftRotation.applyLocal), which matches the original matrix chain: pulling back while banked turns instead of only raising the nose.

PlaneEntity and TankEntity port MCP_EntityPlane and MCH_EntityTank the same way. Their original attitude rules ran once per rendered frame with a filtered partial tick of about 0.5; they now run three times per tick with that value, which reproduces a 60 FPS client exactly.

Pilot gunner mode is a synced flag on AircraftEntity, switched by the server on a CrewActionPacket and dropped there when its conditions stop holding. As in the original, isHovering() is true in hover mode or gunner mode, so a helicopter whose pilot mans the guns simply hovers. PlaneEntity instead runs the original's autopilot: pitch and roll ease out, the yaw turns by AutoPilotRot each frame, throttle input is ignored and vertical motion is damped instead of following gravity, the same level off the target drones use. On the client the pilot stops steering with the mouse, looks around within the seat's pitch limits from the first CameraPosition, and gets the HUD of seat one.

Weapons

Package weapon, with the projectiles in entity.projectile. A port of the original's weapon and flare packages and the weapon half of MCH_EntityAircraft.

  • WeaponDefinition (content.weapon) reads assets/mcheli/weapons. ContentRegistry loads it before the vehicles and drops AddWeapon lines naming a weapon that does not exist.
  • WeaponSet is one selectable weapon of a vehicle: the mounts of consecutive AddWeapon lines, with the magazine, reserve, reload, heat, mode, recoil and barrel spin they share. Firing goes through a Trigger, so the same set logic serves both sides.
  • VehicleWeapons belongs to each AircraftEntity: the sets, the weapon each seat has selected (synced as a string), which mounts just fired (a synced bit mask that drives muzzle effects, recoil and hidden missiles on every client), turret aim, weapon bays and the FlareDispenser.
  • ServerFiring is the server half of each weapon type's shot; it spawns the projectile. ProjectileEntity and its subclasses fly, home and fuse as the original bullet classes did.
  • Explosions computes entity damage with the original curve and DamageFactor, then lets a vanilla explosion of the block size break blocks, so game rules and protection mods apply.
 crew client                                   server
 ───────────                                   ──────
 WeaponController (use key held)
 VehicleWeapons.use(client trigger)
   ready? loaded? locked? target in sight?
   ── FireWeaponPacket(weapon, option1, ─────▶ checks seat and selection
         option2)                              VehicleWeapons.use(ServerFiring)
                                                 spawns ProjectileEntity ──────▶ all clients
                                               synced selection + fire mask ───▶ effects, aim
 ◀─────────────────── WeaponStatePacket(ammo, reserve), to the crew only

Like the original, the client fires first: its weapon copy runs with slightly longer intervals, so it never asks faster than the server accepts, and it is where lock-on happens. The server trusts only the two option values (a locked entity id and a fire mode) and repeats every other check.

Damage enters through AircraftEntity.hurtServer, a port of attackEntityFrom. Projectile rays are clipped against the main hit box and every BoundingBox (clipHitBoxes); the box struck first lends its damage factor to the armour calculation. A vehicle at zero hit points is destroyed: it burns, spins down or falls, explodes on impact and despawns after 600 ticks (30 seconds; the original used 500). Until then each client keeps the wreck burning (wreckFire): flames, a flickering mcheli:glow and embers, dying down over the last 100 ticks it has seen, and nothing under water.

A hit neither tints the vehicle red nor calls markHurt: the vehicle a player drives is simulated on their client, and resending the server's motion on every hit snapped it back a step. For the same reason Explosions does not push a driven vehicle, and pushes an empty one at a quarter of the mob knockback.

Impacts are measured from the position each tick, so they work whichever side simulates the vehicle. Besides the original's tilt and submersion damage, flying into terrain or another vehicle at speed is a crash, and tanks and cars (takesGroundImpacts) are hurt by walls and long drops by the curve in GroundImpact: a share of their hit points that grows with the square of the speed or height between a harmless and a fatal value, before armour. A vehicle wrecked by any of these, or a falling wreck that lands, goes up through crashExplosion: the fuel sized explosion of the original plus Explosions.crashFireball, which only draws a bigger fire.

On the way down, a shot down plane or helicopter goes through crashSpin, with the rates from physics.CrashSpin in common: a plane eases its nose down to a 55 degree dive while its roll and yaw rates build up, a helicopter spins up to 24 degrees a tick and wobbles in pitch and roll. The direction and the wobble phase come from the entity id, so every side computes the same spin without syncing anything; the server simulates the wreck and its rotation reaches clients as usual.

Hull collisions

The BoundingBox lines are the hull, filled in where they leave gaps by model.HullFill: when the content loads, ContentRegistry reads each vehicle's model from the same pack, cuts its surface into one-block cells, drops the cells a listed box already holds and the spinning parts ($blade, $rotor, $rotpart), and stacks the rest into one-cell columns (AircraftDefinition.hull() holds both). Columns stay one cell wide because hull boxes only move their centres with the vehicle, not their shape. Server and client read the same file, so they agree on hits. hullBoxes() returns the hull in world space, axis aligned as in the original, next to the main box. Projectiles and boarding clicks clip against them (clipHitBoxes), and so do other vehicles: move is overridden to undo a step that would push the hull into another vehicle's, first sideways and then completely, which also lets a vehicle rest on top of another. Vanilla entity collision only knows one box per entity, so this check runs on top of it, on whichever side simulates the vehicle. Pairs that may load each other onto a rack, and a carrier and its cargo, are left out. The server's ramming check uses the same boxes and, after a sudden stop, looks one tick of travel ahead, because a blocked step on the pilot's client leaves the hulls just short of touching. With F3+B on, AircraftRenderer outlines the hull through the per-frame gizmo collector.

A projectile hit only does something on the server. Clients still run the same hit test (they know the shooter from synched data, so a round never stops on its own vehicle) and park the round, hidden, where it struck until the server removes it.

Fuel and equipment

Fuel lives in the synched DATA_FUEL, saved as Fuel. The server burns it once a second in burnFuel (the original's updateFuel) and fuel trucks share it in supplyFuel; whichever side simulates the vehicle reads hasFuel() and lets the throttle run down when the tank is empty, so the pilot's client and the server agree without another packet. Vehicles saved before fuel existed load full.

The fuel can, wrench and parachute are plain items (item.FuelItem, WrenchItem, ParachuteItem). Fuel and wrench act in AircraftEntity.interact before boarding, which also covers clicks on the hull outside the main box (they arrive through BoardVehiclePacket). ParachuteEntity is the original's MCH_EntityParachute: server simulated, ridden by the player, steered by the rider's forward input from ServerPlayer.getLastClientInput. Its Kind picks the model: the round canopy for the item, the seat for ejection (whose canopy opens once it falls), the bare canopy for drops.

Placed equipment, throwables and spotting

Equipment that lives in the world is an entity set up by item.PlacedEntityItem: GltdEntity, ContainerEntity and UavStationEntity (whose item sets its kind and facing first). Each is server simulated and eased on clients with a LinearInterpolationHandler.

Throwables are content: content.throwable.ThrowableDefinition in common, loaded from the throwable folder of every source. Like the vehicles there is one item, mcheli:throwable, whose mcheli:throwable data component names the definition, so packs add throwables without new items. ThrowableEntity flies and bounces on the server; clients spawn the mcheli:smoke particle (SmokeParticleOptions, coloured, using vanilla's campfire smoke sprites).

The GLTD calls a weapon.CasStrike, the three-pass A-10 run that vehicles with a CAS weapon also use through ServerFiring. Its sight reuses VehicleCamera's zoom steps and camera modes; the client asks for strikes and night vision with EquipmentActionPacket, which equipment checks against the rider like CrewActionPacket. Night vision for any sight goes through entity.NightVision.

weapon.Spotting is the server half of the rangefinder (SpotPacket) and of targeting pods: a 40 degree cone out to 400 blocks, line of sight, never the spotter's allies (isAlliedTo). Nothing on the target changes. The spotted ids, or a marked block, go to the spotter and their team.TeamManager.teammates in a MarkerPacket; its handler fills team.Markers (plain shared code, so the packet class stays loader and side neutral) and client.TargetMarkers draws them on the HUD with GameRenderer.projectPointToScreen, falling back to a diamond at the screen edge for points behind the camera or off screen.

entity.UavStationEntity is the UAV station and portable controller in one entity, with a kind. Its one slot is menu.UavStationMenu; the launch offset buttons and Continue are container button clicks. The original kept the pilot at the station and let the drone read its controls from there. Movement here is the vanilla vehicle sync, which only runs for a vehicle's own passengers, so the pilot rides the drone's reserved seat 0 instead, hidden and permanently invulnerable, tagged mcheli_remote_pilot. The drone keeps the station's UUID and return point (saved), checks the 123 block range each tick, and on dismount sends the pilot back through getDismountLocationForPassenger. CommonHooks.playerLoggedIn undoes a stale tag, and a saved seat lets the pilot back into seat 0 after a reload.

Troops: AircraftEntity.boardNearbyMobs seats mobs when the pilot boards. dropCrew (the pilot's DROP_CREW action) starts ropes or a parachute drop; jumpOut is a passenger's own way out. A rope descent is an entity.RappelEntity (the original's MCH_EntityHide) that the rider sits on; it follows its hook under the helicopter and lets go at the ground or the rope's end. The ropes themselves are drawn by AircraftRenderer from the synced repelling flag and a rope length both sides let out at their own pace, as in the original.

HelicopterEntity folds its blades from a synced fold state (folded, folding, unfolded, unfolding); each side steps every blade towards the tail or back to the spinning rotor itself, and the renderer reads per blade angles. Search lights are an additive cone (Glow.cone) per lamp in the vehicle's frame. entity.VehicleParticles holds the client side downwash, spray, nozzle and landing gear particles, using mcheli:smoke with a lifetime.

The drafting table is the mod's one block: block.DraftingTableBlock with an eight way rotation and a lamp, an invisible block model and a block entity whose DraftingTableRenderer draws the original MQO. The item uses a small cuboid model made of vanilla textures, since the jar may not carry the original's art. A 26.x client only knows the recipe book, not the server's recipes, so DraftingTableScreen lists the MCHeli entries of the player's recipe book and sends the chosen RecipeDisplayId in a DraftingTablePacket; DraftingTableMenu.craft resolves it through RecipeManager.getRecipeFromDisplay, checks and takes one stack per ingredient (so the vehicle ingredient's component check applies), and assembles the result into the output slot.

team.TeamManager is the operators' team manager: vanilla scoreboard teams, the pvp game rule, respawn points and a sweep of loaded vehicles. TeamPacket carries the screen's buttons and the server checks the gamemaster permission again.

Racks carry ContainerEntity as well as vehicles when their AddRack names container. Cargo other than vehicles that leaves a rack with no ground within 20 blocks rides down on a ParachuteEntity of kind DROP. ChainEntity keeps the towed end within its length of the end that pulls; vehicles and containers hand chain clicks to ChainItem before boarding or opening.

Vehicle inventory and supply

entity.VehicleInventory is the original's MCH_AircraftInventory plus the container half of W_EntityContainer: five equipment slots (three fuel cans, two parachutes) and a 54 slot cargo chest, saved as Equipment and Cargo. The chest always has room for the largest size and the screen shows AircraftDefinition.cargoRows() rows of it, so a content reload that shrinks InventorySize hides items instead of deleting them. Everything drops when the vehicle is removed for good (RemovalReason.shouldDestroy), not when its chunk unloads.

menu.VehicleMenu is the vehicle screen, registered as mcheli:vehicle in registry.ModMenus; the cargo chest reuses vanilla's ChestMenu and GENERIC_9xN types. A crew member opens the screen with CrewActionPacket.Action.VEHICLE_SCREEN; the client side of the menu finds the vehicle as the one its player rides, so the open packet needs no extra data and the menu works on every loader. Both sides always build the same 41 slots, parachute slots the vehicle lacks are simply inactive. The server sends each weapon's rounds and a bitmask of the weapons that can be reloaded now through data slots, because a client only hears about its own seat's ammunition. The screen's buttons go back through vanilla's container button packet (clickMenuButton): ids from zero reload that weapon through weapon.Resupply, BUTTON_CARGO opens the chest.

The supply amounts are in supply.SupplyRules in common: a fuel slot pours up to 100 units every half second while canSupply() (the vehicle stands on something, and floats or is out of the water), an ammunition truck (AmmoSupplyRange) adds a tenth of each weapon's maximum every two seconds, and a repair vehicle (RepairOtherVehicles) mends its neighbours once a second.

Client

CrewController samples keys each client tick and the mouse every frame, for whichever seat the player occupies. For helicopter and plane pilots vanilla turns the view first; the controller converts the change into aircraft rotation, then snaps the view back onto the nose. Everyone else looks around freely within their seat's limits (gunner pitch limits, fixed rotation seats, the tank's free look). Through ViewportEvent.ComputeCameraAngles it tilts the view of every seat with the hull: AircraftRotation.cameraRoll keeps the hull's up axis at the top of the screen as seen along the rider's own view, so a pilot looking ahead gets the hull roll and a passenger looking out of the side gets the hull pitch. Note that Camera.setRotation banks left for a positive roll, the opposite of the hull's convention. For gunner sights and camera positions the controller moves the first person eye with Camera.setPosition, looked up once through ObfuscationReflectionHelper and switched off with a warning if that ever fails.

AircraftRenderer follows the 26.x render state pattern for every kind. extractRenderState copies the interpolated pose, part angles and animation into an AircraftRenderState, and PartRenderer submits the hull, the kind's own parts and the common parts in the original order through submitCustomGeometry. Entity render types draw quads, so MeshRenderer sends every triangle as a quad with a repeated last vertex. GlassSplit sorts each part's triangles by the texture alpha under them: opaque ones draw through entityCutoutCull, glass draws blended through entityTranslucentEmissive, the one entity type that does not write depth, shaded by hand from the light at the vehicle. Without that, glass drawn before water hid the water behind it.

Back faces are culled, as in the original. The models build thin panels from back to back faces, often textured differently on each side, and the 26.x entityCutout type draws both, which makes them fight for the same depth and flicker as the view moves. The glass type cannot cull, so MeshRenderer.emitFrontFaces drops glass triangles that face away from the camera on the CPU: entities are submitted in world-aligned space with the camera at the origin, so that is a sign test on each transformed triangle. Both loaders keep the original's counter-clockwise winding.

ExplosionEffect is the original's explosion look, played on each client from an ExplosionEffectPacket that every MCHeli explosion sends to players within 256 blocks. Crashes set the packet's crash flag for a larger show of our own: the same smoke shell drawn a little above the impact, a cluster of fireballs, a climbing plume of signal smoke and burning fuel, capped at size 12 to keep the particle count in check.

Night glow is our own. Glow holds two additive pipelines that test depth but never write it (the lightning blend, so overlapping glows add up and never cut holes in the smoke behind them), falling back to the vanilla translucent OIT pipelines when Fabulous graphics are on. It draws camera facing halos and tracer ribbons for ProjectileRenderer (rounds and rocket motors) and FlareRenderer (burning flares, including crash debris), and is the layer of the mcheli:glow particle that ExplosionEffect spawns for the flash, the embers and the lingering crash fire. How bright a glow gets comes from effect.NightGlow in common: the block light, sky light and the sky darkening at that spot, so it is faint at noon and full at night, in caves and under water. ClientConfig (config/mcheli-client.toml) turns it off per player.

Iris replaces every vanilla pipeline with its own shader programs and draws unknown ones with a program whose vertex layout does not match. IrisCompat looks up IrisApi by reflection on the first client tick (Iris is optional, so nothing is compiled against it) and assigns the halo pipeline to EMISSIVE_ENTITIES and the particle pipeline to PARTICLES_TRANSLUCENT.

VehicleCamera holds the local crew member's zoom and camera mode, the client half of the original's MCH_Camera. Zoom narrows the field of view through ViewportEvent.ComputeFov; it is offered on the fold key whenever the seat looks through a sight, as the original did. Night vision asks the server through CrewActionPacket, and AircraftEntity keeps a short ambient night vision effect topped up on that seat's occupant, removing it again on leaving (unless a longer effect, such as a potion, was there). Thermal view is night vision plus the mcheli:thermal post effect (assets/mcheli/post_effect/thermal.json, shader post/thermal.fsh), a white hot monochrome with outlines in place of the original's vanilla "pencil" shader, which no longer exists. VehicleCamera.tick keeps it in LocalPlayer.getActivePostEffects(), the list the game reads every frame, so no loader hook is needed.

CrewController also hides the first person hand through RenderHandEvent wherever the original did: for the pilot, and for anyone looking through a camera or with a weapon selected.

EngineSoundPlayer starts one looping EngineSound per nearby vehicle. Volume and pitch follow each kind's original curve; listeners outside the vehicle hear it from a point scaled towards them, which stretches the normal 16 block range to SoundRange.

WeaponController turns the use key into firing (cancelling the vanilla item use while the seat has a weapon), the pick block button and G into weapon switching, and drives lock-on. ProjectileRenderer and A10Renderer draw models/bullets and the close air support A-10, tinted and full bright, with back faces culled.

HUD

The original's HUDs are scripts in assets/mcheli/hud: lines such as DrawLine, DrawTexture and DrawString whose numbers are expressions over about fifty variables (hp_rto, plyr_yaw, reloading and so on), with If/EndIf, Call and Exit. The pure hud package parses them: HudScript.parse turns a file into HudCommand records, compiling every expression once with Expression. That replaces the mcheli.eval library, a copy of a general purpose evaluator that re-parsed each expression every frame; only the arithmetic the scripts use is kept, with the library's evalDouble semantics (all doubles, shifts as powers of two, && returning an operand). HudVariable lists the variables and TextArg the typed values DrawString can format. A unit test parses every bundled script and checks each variable, texture and called script exists.

On the client, HudScripts loads the scripts through the resource manager (F3 + T reloads them), and WeaponHud, the HUD layer, picks the script for the player's seat: the definition's HUD line or the kind's default, or tv_missile while flying a TV missile. HudRenderer runs it against HudState, which ticks the radar sweep (mobs within 64 blocks, as MCH_Radar did), the altitude and the control stick, and fills in the variables each frame. HudCanvas draws the original's GL lines, stippled lines, points and textured quads as unit quads stretched and turned by the GUI pose, since the 26.x GUI takes whole number coordinates but keeps the pose of each element. In third person, for a destroyed vehicle or when the seat's script does not exist, the layer shows a plain text readout instead.

RiderPose tilts players riding a vehicle with its hull, about their hip, through RenderLivingEvent.Pre; the camera eye follows the same up axis.

MeshCache parses models on first use and is cleared on resource reload (F3 + T).

Workshop

The workshop edits a content pack, so nothing else in the mod has to know it exists: a saved vehicle loads, syncs and renders like any other.

The pure half is workshop in common, tested without Minecraft:

  • DefinitionDocument is the definition as lines of text, with the DefinitionReader rules for what counts as an entry. An edit changes one line, so comments and keys the editor has no panel for survive. DefinitionReader.read(String, T) parses it on every edit (a file takes microseconds) and that parsed definition is both what the screen draws and how problems are found, so the editor and the game cannot disagree about what a line means.
  • PartType is a table of the lines the editor places: the layout of each one's values (PartField), and the value counts its parser accepts. Those counts matter, since several parsers read meaning into a line's length: WorkshopPart.with writes the shortest accepted length that keeps every value set, never less than the line had. PartTypeTest feeds every entry through the real parser, so the table cannot drift from it. TuningKey does the same for the tuning page, taking each default from a blank definition.
  • WorkshopProject is one vehicle in one pack folder: create, open, save, export. Saving writes a folder in mcheli/packs because a zip pack stays open while in use, which would make replacing one fail on Windows. Opening a zip makes a folder copy with -edit in its id, which loads after the original and so replaces its vehicles (see load order).
  • ModelImport checks an OBJ and turns what is wrong into messages for its author.
  • EditHistory is undo and redo by snapshot of the lines. Consecutive changes with one merge key are one step, which is what makes a drag or a typed number undo in one go.
  • WorkshopCamera is the orthographic view: project for gizmos, unproject for dragging.
  • AssetImport checks a PNG or OGG before it is copied in. Both would otherwise fail silently in game, an OGG holding Opus most of all, so the codec is read from the file's first page.
  • PackCatalog is what the package browser lists, with nothing left open, and installs a zip.

client.workshop holds the screens. WorkshopScreen hands an AircraftRenderState holding the parsed definition to vanilla's GuiGraphicsExtractor.entity, which draws through the registered AircraftRenderer into a picture-in-picture texture. So the preview is the real renderer with glass and moving parts, on every loader, with no renderer of our own to register. The only difference is inGui: glass is normally culled on the CPU by assuming the camera sits at the origin, which does not hold in a GUI. RuntimeAssets.setWorkshopRoot puts the open pack ahead of the runtime roots, so its model and texture draw before the game has loaded the pack.

Gizmos are 2D GUI drawing on the points WorkshopCamera.project gives, in the stratum above the picture. So they never hide behind the hull, which is where seats and pivots usually are. The camera's rotation and translation go to entity as they are, and a test checks project against them.

A test flight (TestFlightPacket, workshop.TestFlight) saves, reloads the content on the server as /mcheli reload would, and seats the player in a vehicle made with AircraftEntity.startTestFlight. It takes no damage, is not saved with the world, returns its pilot through the same path as a remote pilot and discards itself on its next tick. Only the owner of an integrated server may ask: the pack is on that machine, and on a dedicated server it arrives only by operator approval, when it is no longer a test. WorkshopClient keeps the screen across the flight and reopens it when the player is out, or after five seconds if no vehicle came.

Part textures

PartTexture is a map on the definition from part name to texture name. PartRenderer.submit takes a list of MeshGroups and stays on its old single-texture path when the map is empty, so no existing vehicle pays anything. Otherwise model.PartTextures.assign sorts the groups by texture, following the rule that a $ object owns the plain objects behind it, and GlassSplit caches the result. The buckets stay the same objects from frame to frame, which its identity-keyed glass cache relies on.

A $ object nothing animates would never draw, since it also ends $body, so hull() draws $cockpit right after the body. No model in the 1.0.3 content has one, so this changes nothing there.

An import overwrites the file in the pack at once and calls RuntimeAssets.refreshTexture, then clears the mesh cache, because glass is worked out from the texture's alpha. Only the definition line is in the undo history.

Pack sounds

Loops have always streamed their file through RuntimeSoundInstance. One-shots did not: the server's level.playSound ends at a SimpleSoundInstance on the client, and the sound manager drops an event it does not know from a sounds.json, which a pack cannot have. So a pack's weapon sound was silent. ModSounds.playAt and playTo now check ContentPacks.hasSound and send those sounds as a PackSoundPacket, with vanilla's range, which OneShotSound plays through the same stream path. Sounds from the import or a resource pack still go the vanilla way.

EngineSoundPlayer keeps up to two loops per vehicle, engine and rotor. Both take their pitch from the definition's PitchCurve when it has one, at visibleThrottle, and from EngineSoundSource.enginePitch when it does not. The curve keeps pitch to 0.5 to 2, the range SoundEngine clamps to. WeaponSound sets WeaponSet.ownSound, which only ServerFiring reads, so weapon files stay shared.

Submissions

Any player can reach the upload packet, so it is written as untrusted input and off by default (packs.allowSubmissions).

Send to server   ── PackSubmitPacket(offset, total, 30 KiB) ──▶  network.PackSubmissions
                                                                   │ off, too large, busy: refused
                     PendingPacks.store, off the main thread  ◀────┘ out of order: dropped
                                 │  mcheli/pending/<hash>.zip + <hash>.properties
/mcheli approve  ── PendingPacks.install ──▶ mcheli/packs/<id>.zip ── ContentReload ──▶ every player
  • A serverbound custom payload is capped at 32 KiB, a good deal lower than the 128 KiB chunks of a download, which is why the packet is separate from PackDataPacket.
  • total is checked against maxPackBytes on the first chunk and again on each one. The buffer grows as bytes arrive rather than being allocated from what the client claims.
  • PendingPacks names files by hash, never from anything the client sent, and opens each with ContentPack, which bounds files and unpacked size and refuses anything but pack content. One submission per player and MAX_PENDING overall bound the disk. The manifest's author is unverified, so a submission also records the player the server saw.
  • A zip in use stays open, and Windows will not overwrite it, so install renames the old one to *.zip.replaced-<hash>, which the scan ignores, and cleanReplaced deletes it on a later load. PendingPacksTest does this over an open pack; a test on Windows is the one that counts.
  • With requireApproval off a pack goes live on arrival, unless its id is already in use.

Assets

The vehicle content is EMB4's and is All Rights Reserved, so the jar carries none of it. Players supply the original 1.0.3 zip, and the mod converts it on their machine:

<game dir>/mcheli/MC1.7.10_mcheli_1.0.3.zip      downloaded by the player (any *.zip is tried)
        │  LegacyContent.prepare: import when import.properties does not match the zip
        ▼
<game dir>/mcheli/imported/
  assets/mcheli/...                              resource pack half
  data/mcheli/recipe/*.json                      data pack half
  import.properties                              importer format, zip path, size, time

content.LegacyImporter is pure Java and does the conversion: lower-case paths (26.x identifiers forbid upper case and #), the singular textures/item and textures/block folders, JSON language files, sounds.json with the original grouping rule, an item model per item texture, and the recipes. It writes into imported.tmp and swaps the folder in only when it succeeds: the old import is first moved to imported.old and deleted last, and the renames are retried for a few seconds, because on Windows a just-deleted folder keeps its name while a file watcher or virus scanner still holds a handle in it. Bumping LegacyImporter.FORMAT makes every install convert again (1.1.0-beta.1 bumped it to 2 for the generic item recipes).

Runtime assets

Vehicle content can change while the game runs (/mcheli reload, content packs arriving from a server, and the workshop), and a resource reload is slow and does not exist on a server. The client therefore reads vehicle assets through client.RuntimeAssets: the resource manager first, so resource packs keep the last word, then the runtime roots, the assets/mcheli folders of the content packs in use.

  • Models: MeshCache opens them through RuntimeAssets.open; clearing the cache is enough for edited or new files to load on the next frame.
  • Textures: the renderer asks RuntimeAssets.texture(id) for every texture it binds. A texture only a runtime root has is uploaded as a DynamicTexture on first use; refreshTextures uploads every vehicle texture again from disk. A resource reload releases ours so the texture manager takes over again.
  • Sounds: RuntimeSoundInstance (the engine loop extends it) plays through the sound manager when a resource pack defines the event, and otherwise streams the OGG file a runtime root holds for it through the loader's sound stream hook (SoundInstance.getStream on Forge and NeoForge, getAudioStream on Fabric), with no sounds.json entry and no reload.

CommonHooks.packSource adds the folder (through AddPackFindersEvent, or a mixin on Fabric) for both pack types, as a required pack at the bottom of the list: it is enabled in every world, cannot be turned off, and regular resource packs still override it. Minecraft merges lang files across packs, so the item names from the import and the rework's own keys in the jar's lang files sit side by side; LegacyImporterTest checks they never share a key.

Without a zip or an earlier import, ContentRegistry is empty: the game starts with no vehicles and ClientHooks.loggedIn tells the player in the chat where the zip goes. A zip that fails to import is logged and reported the same way.

The repository holds none of the original files. The unit tests get them the way players do: TestResources imports the zip from Mod/ once into build/legacy-content, so the tests run against exactly what the importer produces. Where the zip is missing (CI), the content tests are skipped and only the tests that build their input in code run. As a guard, the jar task still excludes everything under assets/mcheli except lang and the generic items' fallback models, since build/sourceSets/main can hold copies from older builds.

Testing

Unit tests live in common/src/test and run with ./gradlew :common:test (or build). Platform code has no unit tests; it is covered in game. Apart from targeted cases, the tests load every shipped helicopter definition and model and check that each rotor part referenced by a definition exists in its model. New content is validated simply by adding it. In-game behavior is covered by the manual plan in TESTING.md.