This document explains how MCHeli Rework is organized and why. It reflects the code as of
1.1.0.
- 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.
- 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).
- Keep the core testable. Parsing and model loading do not depend on Minecraft, so they run in plain unit tests against every shipped file.
┌────────────────────────────────────────────────────────────────┐
│ 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'sRegistryObject) that the shared registry classes declare duringMCHeli.init. Forge and NeoForge register them from theirRegisterEvent; 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 is minecraft-26.3 with these differences:
- Rendering API. Pipelines live in
com.mojang.blaze3drather thancom.mojang.renderpearl, there are no order independent transparency pipelines to name, andPoseStackturns throughmulPose.Glowspells out vanilla's eyes pipeline, which has no snippet to build on yet, andProjectileRenderermakes its own translucent culled render type, since vanilla only offers one drawn into the item target. - Interpolation.
Entitydoes not own or run a handler. Each of our entities holds anInterpolationHandler, returns it fromgetInterpolationand callsinterpolateaftersuper.tick(), the way vanilla's boat does. - Thermal view. There is one post effect slot on
GameRendererrather than a list on the player, and its setter is private, so each loader handsClientHooks.inita 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.
InteractionKeyMappingTriggeredis one event withisUseItemandisPickBlock, not an event per key. - Rider pose.
CameraRenderStatedoes not carry the camera entity's partial tick, so Forge and Fabric askClientHooks.cameraPartialTicks. NeoForge's event has it.
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
withSamplerandwithVertexFormatrather than bind groups and a primitive topology. - Screens. They live on
Minecraft, notGui, as they do on 1.21.1. - Teams. There is no
TeamColor. The team manager picks from theChatFormattingcolours. - Block entity types. The constructor is private in vanilla, so
Platform.blockEntityTypemakes them: Forge and NeoForge have opened the constructor, Fabric API has a builder. - Camera hooks.
GameRenderer.updatealso takes whether game time advances, and Fabric's camera mixin shadowsVector3fconstants rather thanVector3fc.
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.
entityCutoutculls here and does not on 26.x, andentityCutoutNoCullis what 26.x callsentityCutout. The names exist on both sides, so a straight copy compiles and turns parts inside out. - Screens.
render,renderBgandrenderLabelsrather than theextractmethods, onGuiGraphicswithdrawStringrather thanGuiGraphicsExtractorwithtext. Container screens set their size aftersuper. - Pipelines.
withBlendandwithDepthWriterather than colour target and depth stencil states. - Interaction. A click on the hull arrives through
interactAt, as on 1.21.1. - Camera.
GameRenderer.updateCameracallsCamera.setup, and NeoForge and Fabric hook after that and at the head ofextractCamera. Field of view isGameRenderer.getFov. - Pack metadata. One
pack.mcmetais read both as client resources and as server data, and each has its own limit: resources refusesupported_formatsabove 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 onCustomIngredientgetMatchingItemsandtoDisplay. - Conventional tags. Forge on 1.21.x has
forge:gunpowderandforge:feathersbut not thec:names our recipes use, so its modules definec:gunpowdersandc:feathersfrom those.
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.
renderdraws straight into aMultiBufferSource, andGeometry.submitkeeps those calls reading the same as elsewhere. - Save data.
CompoundTaghasstore,readandgetIntOr, so it stands in forValueOutputandValueInput. 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
VehicleInterpolationkeeps the target in that case. - Thermal view. Post effects use plain uniforms:
InSizerather than aSamplerInfoblock, and auniformslist 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
itemEntityTranslucentCulldraws 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 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
renderand draws straight into theMultiBufferSource, soPartRenderer,MeshRendererandGlassSplitare unchanged apart from that. The glow uses a render type built fromRenderType's public constructor (vanilla keepsRenderType.createpackage private) and a customParticleRenderTypefor additive particles. - Vehicle icons. There is no
item_modelcomponent. The import writes one item model per kind whosecustom_model_dataoverrides pick each vehicle's model, and lists the numbers inmcheli/imported/item_icons.properties;AircraftItem.stackOfputs the number on the stack. - Recipes take ingredients as
{"item": ...}and{"tag": ...}objects; the import writes them that way forImportTarget.V1_21_1. The marker file records the target, so a folder imported for one version is imported again by the other. - Entities extend
Entityinstead ofVehicleEntity, whose abstractgetDropItemis package private in vanilla, and keep their own hurt wobble data. Save data isCompoundTag, damage goes throughhurt(server only), server position updates are eased in bylerpTolike vanilla's boat, and a click on the hull arrives throughinteractAt. - Camera. Forge 1.21.1 fires its camera event after the camera is placed, so Forge uses the
event. NeoForge and Fabric hook
GameRenderer.renderLeveljust 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
RecipeManagerandDraftingTablePacketsends the recipe's id. - Thermal view. Post effects are the older
shaders/postchains, and vanilla loads programs from theminecraftnamespace only, so ours isassets/minecraft/shaders/program/mcheli_thermal.GameRenderer.loadEffectis private, so each loader handsClientHooks.inita way to call it. - Particles. Smoke is a custom
ParticleRenderTypetoo. Vanilla only draws the types on its own list, soParticleEngineMixinadds ours on Fabric; Forge and NeoForge draw every type in use. - Workshop preview. With no GUI entity renderer,
WorkshopScreendraws throughAircraftRenderer.drawthe 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 theChatFormattingcolours.
minecraft-1.20.6 is minecraft-1.21.1 with these differences:
- Data folders. They still have plural names (
recipes,loot_tables,tags/items), soImportTarget.V1_20_6writes recipes todata/mcheli/recipes, and the jar guard ingradle/loader.gradlekeeps EMB4's out of both spellings. - Vertices. The older builder:
vertex,color,uv,overlayCoords,uv2,normal, and anendVertexto finish each one. - Particle render types.
beginis handed a builder andenda 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
ComputeCameraAnglesstacks it on top of the view with the opposite sign, so its glue negates it and needs no camera mixin. Fabric hooks afterresetProjectionMatrixand wrapsMatrix4f.rotationXYZto do the same. Forge passes the event's roll intorotationXYZitself, where it turns around the world's Z axis, soForgeClientreplaces all three angles withAircraftRotation.rollInXyzOrder, which builds the same matrix. - Conventional tags. Forge has none of the
c:tags, soforge-1.20.6defines all our recipes use, from the vanilla items and any matchingforge:tag. Fabric lacks five, defined infabric-1.20.6. - Respawn.
TeamManagerfinds the respawn point itself, since there is noDimensionTransition.
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.
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.
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.
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.
/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.
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.
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.
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.
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
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.
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.
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.
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.
HelicopterEntity.updateMotion is a port of MCH_EntityHeli.onUpdate_Server:
- gravity from the definition, separate in water;
- lift
(lift * 0.025 + 0.03) * throttle, whereliftfalls linearly to zero as pitch plus roll approaches 83 degrees; - forward thrust
0.1 * currentSpeed * pitch^3 / 30000 * throttlealong the yaw; - a speed cap from
Speed, withcurrentSpeedspooling 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.
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) readsassets/mcheli/weapons.ContentRegistryloads it before the vehicles and dropsAddWeaponlines naming a weapon that does not exist. - WeaponSet is one selectable weapon of a vehicle: the mounts of consecutive
AddWeaponlines, with the magazine, reserve, reload, heat, mode, recoil and barrel spin they share. Firing goes through aTrigger, 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 theFlareDispenser. - ServerFiring is the server half of each weapon type's
shot; it spawns the projectile.ProjectileEntityand 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.
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 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.
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.
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.
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.
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).
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:
DefinitionDocumentis the definition as lines of text, with theDefinitionReaderrules 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.PartTypeis 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.withwrites the shortest accepted length that keeps every value set, never less than the line had.PartTypeTestfeeds every entry through the real parser, so the table cannot drift from it.TuningKeydoes the same for the tuning page, taking each default from a blank definition.WorkshopProjectis one vehicle in one pack folder: create, open, save, export. Saving writes a folder inmcheli/packsbecause a zip pack stays open while in use, which would make replacing one fail on Windows. Opening a zip makes a folder copy with-editin its id, which loads after the original and so replaces its vehicles (see load order).ModelImportchecks an OBJ and turns what is wrong into messages for its author.EditHistoryis 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.WorkshopCamerais the orthographic view:projectfor gizmos,unprojectfor dragging.AssetImportchecks 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.PackCatalogis 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.
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.
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.
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. totalis checked againstmaxPackByteson the first chunk and again on each one. The buffer grows as bytes arrive rather than being allocated from what the client claims.PendingPacksnames files by hash, never from anything the client sent, and opens each withContentPack, which bounds files and unpacked size and refuses anything but pack content. One submission per player andMAX_PENDINGoverall 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
installrenames the old one to*.zip.replaced-<hash>, which the scan ignores, andcleanReplaceddeletes it on a later load.PendingPacksTestdoes this over an open pack; a test on Windows is the one that counts. - With
requireApprovaloff a pack goes live on arrival, unless its id is already in use.
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).
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:
MeshCacheopens them throughRuntimeAssets.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 aDynamicTextureon first use;refreshTexturesuploads 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.getStreamon Forge and NeoForge,getAudioStreamon Fabric), with nosounds.jsonentry 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.
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.