资源图标

原生 Pier - 让 C++ 之外的语言也能写 LeviLamina 模组的装载器 26.51.2

完成注册/登录后下载
[MD] Built for BDS 1.26.51 and LeviLamina 26.51.5, like 26.51.1. `PIER_ABI_VERSION` stays at 2 and no slot moved: a mod built against 26.51.1 loads unchanged. pier-sys-rs is 26.51.2 and pier-rs 2.1.0; nothing public was removed from either (contract §2.5). The Go and Zig bindings are new in this release. ### Added - **The documentation site runs on MkDocs Material and lives on GitHub Pages.** It is laid out as the LegacyScriptEngine documentation is: navigation tabs, a section tree on the left, every entry of the page on the right. The pages under Common tasks, in the Tutorials tab, document one function per entry, with its call form in Rust, Go and Zig, its parameters, return value and type, its slot, and an example in each language, in English and Chinese; their overview page maps the data types between the languages. The home page follows the layout of levimc.org, in Pier's orange: LeviLamina is the green leaves and Pier the fruit. The English and Chinese sites build separately, `mkdocs build --strict` refusing a broken link, and `docs.yml` publishes both to GitHub Pages whenever a release is published. - **The API tab lists every public interface of each binding, generated from its source.** One part per binding: the `levilamina` crate, 792 functions, methods and macros, 117 types and 76 constants; the Go package, 628 functions and methods, 173 of them on `Raw`, one per slot without a callback, with 62 types and 316 constants; the Zig module, 305 functions and methods; and `sdk/abi.h` for C++ mods compiled with the Pier SDK, its 201 slots and 6 macros with every constant table and type. An entry gives the declaration, the source comment, the parameters and return type, and the slots of abi.h the interface reaches, found by following its body through the calls whose target the text names; each slot on the C++ pages lists its callers in Rust, Go and Zig. `tools/gen-api-docs.py` writes the pages and the API section of both navigations, and the `api-docs-current` check fails when a binding changes and the pages are not generated again. Links in both sites are checked down to the heading they name, so a renamed interface fails `mkdocs build --strict`. The Chinese site translates every description: 1541 units in `docs/i18n/zh/api/`, each quoting the English comment it translates and keyed by that comment's fingerprint, and the same check fails when a comment has no unit or a unit's English has left the source. Reading the Go comments against abi.h on the way corrected three that named constants abi.h does not have, and Raw methods that had taken a section note in place of their slot's own comment. - **Documentation and comments describe what happens.** Headings, slogans and closing metaphors that handed over a verdict were rewritten to show the process instead, in the READMEs, the design and why pages, the API pages and the binding comments. COMMENTS.md section 9 says why, and the `prose-tells` check fails on the recognizable constructions in every Markdown file and comment. - **The Zig binding**, for Zig 0.15.2: the package `pier` in `bindings/zig`, exporting the module `levilamina`, with the example `examples/hello-pier-zig`. translate-c reads `abi.h` itself, and `levilamina.slot("name")` is any slot's function pointer behind both gates of contract §10, so the whole table is reachable from the start. On it: the lifecycle through `exportMod`, logging, mod-owned tasks, events, commands, services and the bus under the same rules as the Rust and Go bindings, one method per property and verb on `Player`, `Entity`, `BlockAt` and `Item`, and an SNBT parser with the same rules and depth cap. `zig build test` makes the compiler analyze every function of the binding against the header and runs the parser's tests; a `zig` job in build.yml runs it, checks the formatting and cross-compiles the example to a Windows DLL. `tools/gen-zig-api.py` writes the property methods and the package's copy of `abi.h`, and the `zig-binding` check fails when either is stale or a slot or constant the sources name does not exist. Documented in English and Chinese. - **The Go binding**, imported as `github.com/Maskviva/pier/bindings/go/levilamina` and written `levilamina.…` in code, with the example `examples/hello-pier-go`. cgo includes `abi.h` itself, so the table is laid out by the C compiler and not mirrored by hand, and every slot is reached through a generated C function that applies both gates of contract §10. Its API is within reach of the Rust binding's: every slot without a callback is a typed method of `pier.Raw`, every property and verb of the constant tables is a generated method of `Player`, `Entity`, `BlockAt` or `Item`, and hand-written functions cover the server, world, events with payload parsing, commands with typed overloads, containers, key-value stores, the economy, forms, packet and connection hooks, region scans, bulk block writes, simulated players, custom and supplied-terrain dimensions and client keys, with an SNBT parser of the same rules as the Rust one. Every slot is reachable but the two of lanes, which pair only mods of one toolchain. Its services and bus topics follow the same rules as the Rust binding's, a veto is `true` and a provider's error text reaches the caller unchanged, so it calls and answers mods of any language and native plugins through the bridge. Built with `go build -buildmode=c-shared` and a MinGW-w64 gcc. Documented in English and Chinese. - **`tools/gen-go-slots.py` and the `go-binding` check.** The generator writes the per-slot C functions and the Go module's copy of `abi.h`; the check fails when either is stale, when Go calls a C function nothing declares, or when the glue hands the host a callback no Go file exports. A `go` job in build.yml formats, vets and builds the binding and the example on Windows. - **Rust: `try_` variants for every call that answered "cannot tell" with a value.** `Host::try_pending_tasks`, `Host::try_is_wine`, `World::try_villages`, `World::try_structures_near`, `money::try_history`, `money::try_ranking`, `money::try_clear_history`, `sim::try_list`, `SimPlayer::try_is_simulated` and `KvDb::try_get` return a `Result`, so a host without the slot is an `Err` and no longer the same answer as an empty list, a 0 or a false. The old functions stay, deprecated, with the behavior they had (contract §2.5); `KvDb::get` is not deprecated, since `try_get` cannot tell a closed database from a missing key either. - **The documentation is organized by reader**, the way LeviLamina's is: a user guide, a developer guide split into tutorials, how-to guides, FAQ, architecture and API reference, a maintainer guide, and the supported versions. New pages: troubleshooting, FAQ, and an event payload reference, which documents the attacker fields of death events among the rest. Every existing page keeps its address. - **Contract §2.5: a binding removes nothing while the ABI version stays the same.** It applies to every binding in every language: an item to retire is deprecated through the language's own mechanism, and removed only in the release whose ABI version changes. The adding-a-language guide states it for binding authors. - **The `abi-values` check** keeps every released `PIER_*` constant and enum member at its value, against the baseline `tools/abi-v2.values`. A renumbering done in `abi.h` and the mirror together passed every check before. - **`comment-style` applies the English rule to Rust, Python, TOML and xmake comments**, where Chinese documentation had been sitting unseen. - **The `synthetic-names` check** compares the SDK's `ALL_SYNTHETIC` with the events the host registers, in both directions. The list had drifted by one entry with nothing to see it. - **A watchdog over mod code, on by default.** Every place Pier runs a mod's code is watched: loading and unloading its DLL, `pier_main`, the lifecycle callbacks and every event, command, task, form, bus, service, packet and hook callback. A mod holding a thread past `watchdog.warn_ms` (2000) is named in a warning with the entry and the thread; past `watchdog.hang_ms` (30000), or `watchdog.shutdown_ms` (10000) once shutdown has begun, the process is ended with exit code 70 so a supervisor can restart it. A server that froze on a mod, or never finished stopping, now says which mod and comes back. `watchdog.enabled` turns it off; a limit of 0 turns that one step off. Nothing is ended while a debugger is attached. - **`pier_bridge_call_sink` and `pier_bridge_list_sink`.** The bridge hands a reply over whole, so the provider runs once. `pier-bridge.h` uses them when present and reports through `Client::runsOnce()` whether it can. The bridge ABI stays at 1. - **`registry_list`**: the engine's block, item and entity registries, one JSON object per entry. A block carries its creative category, solidity, shape families, block entity, destroy speed, resistance and light; an item its rarity, stack size, creative category and whether commands hide it; an entity its spawn egg, summonability, experiment gate and actor type bits. In Rust: `registry::blocks()`, `registry::items()`, `registry::entities()`. A mod that hands out random content picks by rules over the game's own content instead of keeping a list of names. ### Changed - **Every binding is written `levilamina` in code**, while its package names Pier: the Rust package `pier-rs` exposes the crate `levilamina`, the Go package `github.com/Maskviva/pier/bindings/go/levilamina` is `levilamina`, the Zig package `pier` exports the module `levilamina`, and `pier-bridge.h` puts its classes in `levilamina::bridge`. `pier::bridge` remains as a deprecated alias of the new namespace (contract §2.5), so a native plugin written against it still builds, with a warning under MSVC and clang. - **The Rust example is `examples/hello-pier-rs`**, beside `hello-pier-go`, `hello-pier-zig` and `hello-pier-cpp`. Its manifest name is `hello-pier-rs` and its entry `hello_pier_rs.dll`; a server with the old example installed keeps a `hello-pier` folder that the new build does not replace. - **The Rust mod template is `pier-mod-template`**, formerly `pier-rs-mod-template`. It holds one Rust mod, to start from with GitHub's *Use this template* or `cargo generate`; the documentation of the other languages starts from their examples. - **Rust: `sys::PierMoneyEvent` is a transparent `i32` newtype** with `PIER_MONEY_*` constants. It was a Rust `enum` receiving whatever the economy backend sent, which is undefined behavior for an unlisted value, so it is the soundness exception of contract §2.5 and changed at once. `PierMoneyEvent::Set`, `Add`, `Reduce` and `Trans` still compile, as deprecated constants, and `MoneyEvent::kind` keeps its type; the new `MoneyEvent::kind_checked()` returns a `MoneyEventKind` with an `Unknown(i32)` arm. What still breaks: a `match` on the old enum with no wildcard arm, which is now non-exhaustive, and `as i32` on a value, which is `.0` now. - **An event payload carries `"partial":1` when a field could not be read**, in `ExplosionEvent`, `FarmlandDecayEvent`, `SpawnItemActorEvent`, `ChestPairEvent` and `PistonPushEvent`. The fields kept their defaults before, such as `sourceIsPlayer:0` or an empty `attached` list, which read as answers. The field is absent when every read succeeded, so a payload that was complete is unchanged. - **Rust: `World::sleep_status` is an `Err` when a field is missing**, instead of reporting nobody asleep. **`EventPayload::check_complete` is false for a payload that did not parse**, which it called complete, and `dim()` says that the payload could not be parsed. - **`kvdb_is_empty` answers false for a handle that is not open**, like every other `kvdb_*` slot, instead of reporting a closed database as empty. - **`PlayerAttackTargetEvent` and `PlayerInteractEntityEvent` carry `targetKnown`.** When the target cannot be read it is 0 and `targetIsPlayer` is 1, so a PvP rule refuses. It was `targetIsPlayer:0`, which let the attack through. - **Four slots that always failed are NULL**: `edit_spawn_entity_nbt`, `level_find_path`, `item_set_enchants` and `client_get_screen_name`. A binding now reports them as not provided instead of as a failed call. ### Fixed - **The documentation put Pier mods in `mods/`.** They live in `plugins/`, beside Pier itself, where LeviLamina looks for every mod; a mod placed in `mods/` was never loaded. `/pier load` now names the real path when a manifest is missing. - **The `typed-storage` surrogate runs clean against real LeviLamina headers.** It reported seven correct sites, through two faults of its own: a type written `X const` was judged by the word `const`, and any name held by several classes was skipped even when every spelling got the same verdict, which left members such as `mCause` unchecked. Names that a class template or Pier itself declares as plain members are now ambiguous instead of misattributed. It judges 10446 members, up from 9559, and Pier passes. - **Kill, hurt and death events name the attacker.** `MobDieEvent`, `ActorHurtEvent` and `PlayerDieEvent` carried only the damage cause. The source now has `attackerUid` and `attacker`, and for a projectile `projectileUid` and `projectile` too; the attacker of a projectile is the actor that fired it, as LegacyScriptEngine and iListenAttentively resolve it. Every described actor carries `uid`, and a player also `xuid` and `realName`. - **`SpawnItemActorEvent` fires again.** It hooked `Spawner::spawnItem`, while the level's spawner is a `BedrockSpawner` that overrides that function, so the detour installed and never ran. `spawnMob` and `spawnProjectile` are inherited, not overridden, so the hooks on them were right and stay on `Spawner`. - **`PlayerChangeGameModeEvent` hooks `ServerPlayer::setPlayerGameType`**, the override every server-side player goes through, as iListenAttentively does. - **`player_set_num` works.** Level, experience, hunger, saturation and exhaustion always failed; they are written through the attribute map, and a level through `addLevels`, the way LegacyScriptEngine does on this engine version. - **A failed detour install is reported.** The profiler, tick control and TPS statistics recorded a failed install as done, so `profile_begin` and `tick_freeze` returned true while nothing was measured or frozen. - **An economy event of a type Pier does not know reaches no listener** and is logged once, and an exception from a listener counts as a veto instead of unwinding into LegacyMoney. - **Registering a supplied-terrain dimension no longer races chunk generation**: the terrain table is locked. - **A wrongly typed field in a dimension spec is reported.** `"seed":"123"` became seed 0 silently; it now leaves a warning naming the field. - **A volume pack whose palette passes 65536 entries no longer hangs the server** in a loop whose counter wrapped. - **Rust: deeply nested SNBT is an error and not a stack overflow.** The parser stops at 512 levels; running out of stack aborted the process, past every panic fence. - **Rust: `Invocation::origin()` keeps the name on a `register` command.** A plain name is itself valid SNBT, so it parsed and came back empty. - **Rust: `Player::last_death_pos` reports an unreadable dimension** instead of answering the overworld. - **Rust: eight slot reads check the table length first.** `host` and `dimensions` read fields such as `gaming_status` and `md_pack_inspect` straight from the table, past its end on a host older than the SDK. They go through `opt_slot!`, which applies the gate. - **A dimension whose definition shape cannot be aligned is refused**, as the line it logs says entering it is not safe; it used to be registered anyway. - **`/pier unload a b` is refused** instead of unloading `a` and reporting success. - **The chunk trace summary counts transitions and loads**; both counters were never incremented and always read 0. - **`send_message_typed` logs a type outside 0..11** once, before sending it as raw text. - **Documentation that disagreed with the code**: the protocol-version and `md_is_available` comments of `abi.h`, the key unregister and money listener slots, the MoreDimensions note of `native_dimensions.h`, the file names and counts in `MIGRATION.md`, the mirror comments of pier-sys-rs, and the README and installation pages, which still named LeviLamina 26.40.0. - **A bridge call with a reply over 2 KiB no longer runs the service twice** on a host that has the sink form. The buffer form fetched a long reply by calling again, and a service that changes state did so twice. - **A service provider can no longer be unloaded in the middle of a call from another thread.** Service calls were not counted as callbacks in flight, so an unload on the server thread could unmap a provider still running on a bridge caller's or a worker's thread. Bus, packet and connection dispatch from other threads now recheck after being counted, and the counter and the unloading flag use sequentially consistent ordering: with release and acquire, x64 may reorder each side's store with its following load and both sides miss each other. - **An exception out of `pier_main`, `on_enable`, `on_disable` or `on_unload` is caught and reported as a refusal** instead of unwinding through the loader. - **The vtable handshake keeps the append-only promise.** The host required `struct_size` to be at least its own `sizeof(PierModVTable)`, so appending a lifecycle callback would have refused every mod already built, and a mod built after such an append would have written past the host's struct. `pier_main` now writes into 512 zeroed bytes, the host copies the prefix it knows, and any `struct_size` that covers `on_unload` is accepted. - **Nine `PierApi` slots declared with `()` now say `(void)`.** In C, `()` declares no prototype, which `-Wstrict-prototypes` warns about and which a C translator such as Zig's turns into a variadic function. The calling convention is unchanged. - **`examples/hello-bridge` builds against LeviLamina 26.51.5**, the version the host is built against, instead of 26.40.0. - **The C++ example and pages** stop exceptions at every callback, check the host's ABI version and target before filling the vtable, and include `<cstddef>` for `offsetof`. - **A block container query no longer logs "Access violation - no RTTI data!"** for a block entity that carries no RTTI. `resolveContainer` reached a block's container through `dynamic_cast<VanillaBlockActor*>`, which throws `std::__non_rtti_object` on such an object instead of yielding null; the API guard caught it and logged it on every call. The cast now answers "no container" for it, as it does for any block entity that is not a container. - **The server no longer crashes the first time anyone uses a nether or end portal.** The detour on `DimensionRegistry::registerDimension` declared its id as `DimensionIdType`. The SDK gives `NewType` a hand-written copy constructor, so the detour expected the id behind a pointer, while the engine passes its trivially copyable `NewType<ushort>` as the value itself. Building the nether or the end on first use handed the detour 1 or 2, which it read as an address. The detour now takes the `ushort` the engine passes and is installed by address; the host's own call that registers a custom dimension passes the value the same way. - **The money guard no longer asks LeviLamina's symbol resolver for `LLMoney_Get`.** With LeviLamina 26.51 and LegacyMoney 0.22 installed and enabled, that lookup came back empty, every `money::*` entry point stayed inert for the session and the log said the export could not be resolved. The guard now asks the Windows loader about `LegacyMoney.dll`, the module the delay-load helper binds to, and checks every `LLMoney` function Pier imports by the name it imports it under. The answer is the one the first real call would get. When something is missing the warning names it and lists the `LLMoney` exports the DLL does have, so a version mismatch can be read off one line. The export table is read by `pier/api/pe_exports.h`, which has a test in `tools/pe-exports/`. [/MD]
[MD] Built for BDS 1.26.51 and LeviLamina 26.51.5. A release is built against the one BDS its number names, so 26.40.3 stays the build for BDS 1.26.40 and does not run on 1.26.51. None of the engine changes reaches the ABI: `PIER_ABI_VERSION` stays at 2 and a mod built against 26.40.3 keeps working without a rebuild. One behaviour does change for mods: an ability or permission level written before the player has finished joining is now refused, see below. ### Added - **Literal subcommands in `register_command_ex`.** A parameter of kind `text`, with the word in its `text` field, declares a literal: a node in the command tree, which is what `/scoreboard objectives add` is made of, and the only form the client narrows as the player types. An `enum` parameter lists its values but is not a tree node, so the client had nothing to filter on, and the spec had no way to ask for a literal. The handler receives the word under the parameter's name like any other argument, so dispatch does not depend on an overload index that moves when an overload is inserted above it. Two overloads that differ only by a literal digest differently, so a reload cannot rebind one onto the other. In pier-rs this is `OverloadBuilder::text(name, literal)`. A host before 26.51.1 rejects the kind as unknown and does not register the command. ### Changed - **Ability and permission-level writes wait until the player has finished joining.** `PIER_PACT_SET_ABILITY` and `PIER_PACT_SET_PERMISSION_LEVEL` return false, with a warning in the log, until `Player::isPlayerInitialized`. Written earlier, the change reaches a client that has not built its ability layers yet: server and client then disagree, and the player can be left unable to build or attack until they rejoin. The write is refused rather than queued, so a caller that tried too early finds out at the call instead of seeing the change land at an unrelated moment. Simulated players have no client and are exempt. A mod that sets abilities while a player joins has to make the write once the player has fully joined. - **`PIER_SRV_PROTOCOL_VERSION` knows 1.26.51**, which speaks 2193, read from the startup banner of a 1.26.51 server like the 1.26.40 row before it. - **Custom dimension registration passes the whole definition.** `DimensionManager::_registerCustomDimensionWithDimensionDefinitionGroup` is gone in 26.51. Its replacement, `_registerDimensionWithDimensionDefinitionGroup`, takes a complete `DimensionDefinition`, so the height and the generator go in at registration instead of being corrected afterwards. The definition carries the 1.26.50 `minecraft:dimension_height` shape, `mMinY` and `mHeightRange`, in place of a minimum and a maximum. The top of a dimension is `mMinY + mHeightRange`, and the dimension-data trace prints it that way. - **`MobEffectInstance` has its two-argument constructor back.** 26.51 exports `MobEffectInstance(id, duration)` again and declares the default constructor without defining it. Adding an effect builds the instance through that constructor and assigns the amplifier and the visibility; every other field is what the engine's constructor sets rather than value-initialized. - **`Common::getGameVersionString` is gone on the server.** `PIER_SRV_BDS_VERSION` reads `Common::StringConstants::mGameVersionString` instead, built once through `Common::_buildStringConstants`. - **`GameMode::useItemOn` takes a `HandSlot`**, and the `PlayerUseItemOnEvent` hook passes it through unchanged. The event payload is unchanged. - **`VoidGenerator` takes the default biome as a constructor argument.** A void dimension passes the biome its spec names, or plains when that one is missing, as before. - **`ChunkState` lost `NeighborAwareUpgradeNeeded` and `NeighborAwareUpgrading`**, and the chunk trace no longer names them. - The Rust binding moves to pier-rs 2.1.0 / pier-sys-rs 26.51.1. [/MD]
[MD] ### Added - **A mod that is not a Pier mod can call a Pier mod's services.** `pier_bridge_call`, `pier_bridge_list` and `pier_bridge_abi` are exported from the host, and `bindings/bridge/pier-bridge.h` is a header-only client that binds them. Until now the registry was reachable only with a `PierModHandle` the loader issues, and the only exported symbol pointed the other way, so a permission manager whose value is being the backend everyone shares was reachable only by mods that had already adopted Pier. The registry is unchanged: `api_service_call` already accepted a null handle and its self-call check was already conditional on one. What a bridge caller lacks is identity, which is a real limit and is documented as one -- a provider that grants anything on the strength of the caller alone must not be reachable this way. See `docs/guide/bridge.md`. - **`PlayerEditSignEvent` and `PlayerOperatedItemFrameEvent` are raised.** Both were declared in `ALL_SYNTHETIC` and listed as names, and no hook in the tree produced either, so a subscription succeeded and never fired. A protection mod gating on them reported a refusal its own operator could see while the player edited the sign anyway. Sign editing goes through `SignBlockActor::_playerCanUpdate`, which is the engine's own gate and is asked before the text is taken from the packet; the item-frame right click goes through `ItemFrameBlock::use`. Both are in `CANCELLABLE` now. - **Every player event with a position carries `block`, and every one with a player carries `item`.** Injected by the enricher rather than by each hook, because the events that need them are LeviLamina's own and have no hook here to extend. A consumer subdividing a permission by what was touched had nowhere to get the answer: the payload named the action and never the object. `block` is the block at the event's coordinates and `item` is what the player held. Both are absent rather than empty when they cannot be read, since a name invented here is indistinguishable from a real one and a rule keyed on it fires on the wrong block. - **`PlayerOpenContainerEvent` carries `container` and `block`.** The payload had only a numeric `containerType`, so a subscriber wanting to tell a chest from a dropper had to carry its own copy of a network enum. `container` is that enum as a word and `block` is the block's type name, which is empty rather than a placeholder when it cannot be read: an invented name is indistinguishable from a real one, and a rule keyed on it fires on the wrong block. ### Fixed - **`PlayerRequestItemActionEvent` is declared observation-only.** Nothing raises it and there is no point at which it can be cancelled; `is_cancellable` answered `None`, which a caller reads as "unknown" rather than "no". It now answers `Some(false)` with where to block instead, which is what stops a protection mod from treating a subscription to it as a working gate. ### Changed - **Translations are files again, not a generated C++ table.** `packages/pier-support/src/LangShipped.inc`, `tools/embed-lang.py` and `tools/checks/lang_embedded.py` are gone. The host reads `plugins/Pier/lang/<code>.lang` and nothing else is compiled in beyond the built-in English, so a language is a file an operator drops in and edits rather than a table only a rebuild can change. The packaging half that made the table necessary is handled where it belongs: the `after_build` step in `xmake.lua` names `en_US.lang` and `zh_CN.lang` explicitly and fails the build when either is absent, so `bin/Pier/lang/` carries both and a release cannot ship half-translated. A glob alone still succeeds on one file, which is the shape that let the directory go missing before. - `lang/en_US.lang` is added and carries every key. It is what a translator copies and what an operator edits; `i18n_keys.py` now fails when it is missing a key, when either shipped file is absent, or when a placeholder count disagrees with the English. ### Fixed - **The lang directory never reached an installed server, so every log was English.** `loadLanguages` reads `getModDir()/lang`, modpacker packages the target and `manifest.json` and nothing else, so `lang/` sat in the repository and shipped in no archive. Nothing reported it: a missing lang directory is not an error, and the one line that would have said so only printed when keys loaded. The startup line now prints on every boot rather than only when a file was found, and reports how many lines the active locale has rather than how many a file contributed. Suppressing it when there is no file would leave exactly the gap this closed. - **`PIER_SRV_PROTOCOL_VERSION` reported 2169 for BDS 1.26.40, which speaks 2168.** The row came from a published metadata table rather than from a server; the banner on a real 1.26.40.8 reads `ProtocolVersion 2168`. A consumer mapped the 2169 back through its own table and announced the server as 1.26.45 — a confident wrong answer on a clean boot, which is the failure the key was rewritten to stop producing. The 1.21.93 row went with it: it was taken from a save file's `NetworkVersion` tag, which is the same class of unchecked evidence. An unlisted version fails the key, and failing is the correct outcome. 2168 covers 1.26.40 through 1.26.44 and 2169 is 1.26.45, but only 1.26.40 is listed — a range known second-hand is the evidence that produced the wrong row in the first place. - **`tools/checks/i18n_keys.py` truncated any English value containing `{},`.** It cut the value at the first `},`, which also matches a placeholder followed by a comma, so `... as id {}, registry id {}` was read as having one placeholder. A correct translation was then reported as having too many. It now collects the C++ string literals properly. ### Added - Seven more log keys: the per-dimension boot lines an operator reads to answer "did my world come up the way I wrote it" (`dim.height.set`, `dim.registered`, `dim.ready`, `dim.terrain.layers`, `dim.terrain.end`), and the host's own ready line (`host.ready`). The grid clause of the layers line is its own key rather than a hardcoded tail, because a translation that cannot move that clause has to reorder the sentence around it. ### Added - **Three events that used to need iListenAttentively are hooked here.** `PlayerEditSignEvent`, `PlayerOperatedItemFrameEvent` and `PlayerRequestItemActionEvent` moved from `ALL_FROM_ILA` to `ALL_SYNTHETIC`. Each closes a hole rather than adding a convenience. Placing a sign is a block place and was covered; editing the text of one already there was neither a place nor an interact. Taking an item out of a frame was covered by `PlayerAttackItemFrameEvent`; rotating it was not. Opening an enchanting table is an interact and was covered; everything after that arrives on the item-stack request path and was not. Taking them off another mod is deliberate. A protection feature that only works when a third-party mod happens to be installed is not a protection feature, and the version matrix was already BDS × LeviLamina without adding a third pin that this repository cannot fix when it lags a BDS release. `PlayerRequestItemActionEvent` reports only the `Craft*` action types, as an `action` word (`craft` / `creative` / `anvil` / `grindstone` / `loom`) rather than the engine's enumerator number, which shifts when Mojang inserts a type. Take, place and swap are not reported: several arrive per click, and a cross-mod dispatch on that path would cost more than it protects. [/MD]
[MD] Built for BDS 1.26.40 and LeviLamina 26.40.0. Two fixes, both of the same shape: a value that was wrong rather than missing, and therefore acted on. `PIER_ABI_VERSION` stays at 2. The two new `server_info_str` keys are additive — a mod built against 26.40.0 keeps working without a rebuild, though see the note on `PIER_SRV_PROTOCOL_VERSION` below before relying on the number it used to return. ### Fixed - **A per-player sidebar stopped updating after its first send.** `PIER_PACT_SIDEBAR_SET` updates a sidebar of unchanged row count in place, sending only the rows whose text differs. The client stores a row as `scoreboard id -> (name, score)` and reads a `ChangeFakePlayerScore` for an id it already knows as a score update, not a rename: the number moved, the label did not. So the first send was correct and every later one silently did nothing. A changed row now releases its id with a `RemoveScore` before the `ChangeFakePlayerScore` re-adds it. The removes go out as their own packet ahead of the changes: nothing here can verify that a client applies one packet's entries in order, and a remove applied after the re-add blanks the row instead of updating it. If the second packet cannot be created after the removes have gone out, the cached state is dropped so the next call rebuilds from scratch — keeping it would leave those rows blank until the row count changed. - **`PIER_SRV_PROTOCOL_VERSION` reported the save file's protocol, not the server's.** It read `LevelData::mNetworkVersion`, which is a storage tag sitting among `mStorageVersion`, `mMinCompatibleClientVersion` and `mLastOpenedWithVersion`, and records the version the level was last written by. A 1.21.93 world opened on a 1.26.40 server answered 819. This looked correct for as long as the test machine's world had been created by the server running it. It is now derived from `SharedConstants::CurrentGameSemVersion`, which unlike `NetworkProtocolVersion` is not behind `#ifdef LL_PLAT_C` and so is readable on a dedicated server, mapped through a table of confirmed versions. **On a version the table does not list, and on any pre-release build, the key now fails.** That is deliberate. The old behaviour was not a crash, it was a confident wrong answer, and a caller handed a plausible number acts on it. A pre-release shares its `major.minor.patch` with the retail build but not its protocol — 1.26.40-beta.0 speaks 2168 where retail 1.26.40 speaks 2169 — so a build carrying a `mPreRelease` is refused rather than answered from a retail row. The table matches the exact triple: Bedrock bumps the protocol on patch releases, so matching on `major.minor` would return a number from the wrong end of a release line. ### Added - **`PIER_SRV_LEVEL_PROTOCOL_VERSION`**, the `LevelData::mNetworkVersion` value under the name it deserves. It answers how old the save is, which is a real question and exactly what the old key was accidentally answering. `Host::level_protocol_version` on the Rust side. - **`PIER_SRV_GAME_SEM_VERSION`**, `"major.minor.patch"` of the running build, so a mod can carry its own version-to-protocol table. A Pier release should not be on the critical path for a mod supporting a BDS that shipped yesterday. `Host::game_sem_version` on the Rust side. - `pier-probe` covers `server_info_str` keys 0 through 3. A `Refused` on key 1 is recorded as the correct answer for an unlisted version rather than as a fault; comparing it against key 2 shows directly when a save is older than the server. ### Added - **A mod can supply its own terrain, and this host stops trying to understand it.** `md_add_dimension_generated` registers a dimension whose spec carries seed, height and sky and no terrain section at all; the mod hands over two palettes, resolved once here against the block and biome registries, and a callback the chunk workers call with a buffer of indices. `md_set_dimension_cells` gives the confinement rules the grid the mod generated, since the hooks are engine hooks but the geometry is not. What crosses the boundary per chunk is indices and never names: a chunk is 98304 entries and looking names up per chunk is the difference between a generator and a stall. The threading contract on `PierGenerateChunkFn` is the opposite of the rest of this file and is written out there rather than left to the default. A payload with no terrain section is what says the terrain is a mod's, so `md_add_dimension` refuses one -- taking it there would register a dimension nothing fills and report success -- and a supplied dimension whose mod is gone loads as a void with its chunks intact rather than falling through to the pack branch, where reading the terrain as a pack would throw on a chunk thread. - **`terrain.kind: layers` is served again, and needs no tool and no file.** A layer stack, optionally cut into a grid of cells and gaps, is what a plot world and a superflat world are, and it had been withdrawn in favour of terrain packs built offline with `tools/pier-pack`. A caller now sends the recipe inline, as before, and it is assembled into a template pack in memory and handed to the pack path: the chunk fill, the layout expansion and the confinement hook underneath are the ones the pack path uses and the ones the tests cover, so this adds a front door rather than a second generator. `pierpack/from_layers.py` performs the identical mapping offline and stays the reference. Every expression the assembled pack carries is a constant, because the inline recipe names concrete numbers, so nothing has to be bound and no parameter can fail to bind. A recipe whose border does not fit inside its cell, or whose stack runs past the top of the dimension, is refused with the numbers rather than quietly shrunk. `terrain.kind: noise` is still not served inline: it is a volume pack and not a layer stack, and the in-memory assembly does not reach it. ### Fixed - **A setting with no implementation behind it is refused, not recorded.** `aquifers_enabled` and `ore_veins_enabled` each had a bit in the volume pack format and nothing anywhere that read it: the builder wrote the flag, the host loaded the pack, and the terrain came out without the aquifers or the ore veins its author had asked for, with no line on either side. The tool refuses the two keys now, the host refuses a pack whose settings carry either bit, and a bit outside the known set is refused as well, so a pack from a newer tool cannot be half-honoured. The bit values stay recorded so their meaning is fixed if either is ever implemented. - **A source key the builder does not read is refused.** Neither pack builder validated its top-level keys, so a misspelled name, or a field copied from a Java datapack that this format has no equivalent for, was dropped without a word and the pack was built as though the author had not written it. Both builders now carry the list of keys they act on and refuse anything else. - **The shipped terrain packs are built for the world bottom that exists.** `fixtures/town.json`, `fixtures/plot.json`, `pierpack/from_layers.py` and two of the tool's tests all declared a bottom of -512, the value `kWorldMinY` held before it was lowered to the vanilla -64; nothing updated them at the time. A pack made for -512..320 does not fit a dimension of -64..320, so mounting one was refused with `PIER_PACK_HEIGHT` and no world could be built from it. The declared range is -64..320 now, the empty bottom layer that ran from -512 to the bedrock is gone, and the two equivalence tests mount at the range the packs actually carry. - **A binary the host cannot open names the path it tried.** `binary` in a pack config is joined onto the config's own directory, and a config written with a path relative to the server root instead produces a doubled prefix; the refusal said only that a file could not be opened, which sends the reader to check a path the host never tried. - **A mistyped field in a spec is a refusal with a reason, not an exception.** The conversions on `CompoundTagVariant` are not uniform: a number reached through the wrong type throws `std::runtime_error`, a string does it through `std::get` and throws `std::bad_variant_access`, and `.get<CompoundTag>()` does the same for a section. Any of them escaped the reader, was caught by the API guard several frames away, and came back as a registration that failed naming neither the field, the dimension, nor the recipe. Every field is now type-checked before it is read, a wrong one reads as absent and is reported, and `md_add_dimension` names the dimension and the spec text if anything still throws. - **The host's startup line carries a build stamp**, so a report can be told apart from one taken against the previous binary without asking. - **`md_add_dimension` no longer throws on a layers terrain.** The slot refused a pack terrain and then read the spec as native with `std::get`, which throws for the third alternative rather than falling through. A layers recipe reached that line and the registration came back as a refusal with no reason attached, since the throw was caught by the API guard. Both served kinds are read with `get_if` now, and the biome a layers recipe names is verified at registration for the same reason the pack path verifies its own: `TemplateGenerator` reports a biome it cannot find as the registry having changed underneath it, which is the wrong thing to tell someone who mistyped a name. - **This host owns the dimensions it registers.** The instance ledger held a `WeakRef`, and the engine does not keep an `OwnerPtr` handed to `DimensionRegistry::registerDimension` alive: the reference the call returned locked, the level listed the dimension, and the weak reference was expired again as soon as the frame that built it returned. Every path that reads the ledger treats an empty answer as permission to build, so the same dimension was built and registered twice within a millisecond, and the second registration replaced an object the engine had already been told about during `initializeDimension`. The next player to connect into it dereferenced a null and the server died before they had spawned. The ledger now holds an `OwnerPtr`, it is asked before the engine on every path that decides whether to build, and letting go is a handover to the quarantine rather than a destruction, because a player may be standing in the dimension at that moment. `native::forgetAllInstances` releases them while the level is still standing, rather than among the static destructors at process exit. - **The by-name ledger answers to the qualified name too.** The engine calls by `native::engineNameOf`, the ledger is keyed by the name the caller chose, and the detour on the by-name overload therefore handed the engine's own call straight back to it. - **A refused registration no longer takes the server down with it.** `initializeDimension` is where the engine wires a `Dimension` into the level, and the references it hands out there it does not own. `buildAndRegister` moved its only `OwnerPtr` into `registerDimension`, so a refused registration destroyed a dimension the engine had already been told about, and the level tick reached one of those references about a second later and aborted the process. The reference is now kept across the call, and a dimension the registry declines is held rather than released. - **An empty return from `registerDimension` is no longer read as a refusal on its own.** Whether the dimension was stored is asked of the level, through `ILevel::forEachDimension`, and an empty reference beside a dimension the level is holding is reported as the return value being unreliable rather than as a failed registration. - **A dimension is in the host ledger before anything builds it.** The detours on the resolution calls decide whether an id belongs to this host by asking the ledger, and the ledger was written only after registration returned. During its own registration a dimension was therefore invisible to them, and the call went to the engine, which built and registered it on a path this host does not see. - **A player is no longer sent into a dimension their game has not been told about.** The set of dimensions reaches a client once, in the data sent while joining, and there is no way to send it again mid-session, so a dimension registered while a player is online is one that player cannot enter: the transfer runs on the server, the client has no definition for the id, and the player is left standing where they were with nothing said. `Level::requestPlayerChangeDimension` now refuses the transfer and tells the player to rejoin. A player whose session start this host did not see is allowed through. - **The startup check on the vanilla dimensions asked the wrong question.** The nether and the end are built on first use, so a boot with nobody in them holds the overworld alone, and reporting that as two destroyed dimensions was wrong. Only a missing overworld is reported now. - **A custom dimension no longer takes the overworld's slot in `DimensionRegistry`.** A `Dimension` carries two numbers: `mId`, a `DimensionType`, the signed int this host, the commands and the save all speak, and `mRegistryId`, a `DimensionIdType`, the unsigned short that keys `DimensionRegistry::mDimensions` and that `registerDimension` takes. The only thing mapping between them is `DimensionManager::mDimensionNameIdStore`, the table `serverRegisterCustomDimension` wrote until 26.20 and that nothing exported writes now, so a dimension built for a name that table has never held is constructed with a registry id that is not its id. `registerDimension` assigns into the map, so such a dimension is stored over the slot that number belongs to and the `OwnerPtr` that was there is dropped. The level goes on running against a destroyed dimension: the first custom dimension of a boot reports success, a second is refused because the slot is taken, and the server dies shortly after anyone joins, on a chunk worker or in the level tick, inside engine code that names neither this host nor the dimension that was added. The terrain the new dimension generates has no bearing on it. `native::claimRegistryKey` now sets the registry id of any dimension at or above `firstCustomDimensionId` to its own `DimensionType` before it enters the registry, and a detour on `DimensionRegistry::registerDimension` applies it to the engine's own creation paths as well. `buildAndRegister` reports the as-built registry id on the line it already writes, and refuses to register when the two still disagree. Chunks a dimension wrote under a registry id it no longer holds stay in the save and are not reachable from it. - **Teleporting into a custom dimension lands in that dimension.** Every engine path that resolves a dimension goes through `getOrCreateDimension`, which maps an id to a name through `DimensionManager::mDimensionNameIdStore`. Nothing exported writes that table since `serverRegisterCustomDimension` was inlined away, so it comes back empty for every dimension this host registers. `TeleportCommand::computeTarget`, which is what `Actor::teleport` and the teleport commands run on, only attaches a `ChangeDimensionRequest` when it can resolve the destination, so without one the player is moved to the coordinates inside the dimension they were already standing in. The host had worked around the same gap for its own calls by building the instance into `DimensionRegistry` directly, where the plain `getDimension` lookup does find it, which is why block writes worked while teleports did not. Four detours now answer for this host's dimensions out of that registry, on both `getOrCreateDimension` overloads, on `Level::getOrCreateDimension` and on `Level::isDimensionTypeActive`; every other id goes to the original. Nothing is invented: an id is answered only when the host ledger holds its name and an instance is registered under it. - **A custom dimension is no longer rebuilt over the one already running**, which crashed the server on the first teleport into it. On BDS 1.26.40 both `getOrCreateDimension` overloads come back empty for a custom dimension, so every `getOrCreateByName` fell through to `buildAndRegister`, and `DimensionRegistry::registerDimension` assigns into its map: the second registration under the same id destroyed the `Dimension` the level, the players and the chunk threads were holding. The log showed it plainly, with `built through the factory and registered as id 3` twice at startup and once more when a player was sent in, followed by an access violation on a chunk worker thread with no frame of the mod on its stack. `getOrCreateByName` now asks `DimensionManager::getDimension` first and returns the live instance, `buildAndRegister` refuses an id that already carries one, and a registry that cannot be read refuses to build rather than guessing. A correct startup now logs the build line once. - **The registry table is read directly; `getDimension` is not the judge.** The fix above asked `DimensionManager::getDimension` whether an id carries an instance. On a live server it answered empty for a dimension that was registered and running, so every teleport still went through `buildAndRegister`, and `registerDimension` then returned an empty reference twice and a full one the third time for the same id and the same code. The lookup resolves a `DimensionType` through the engine name table, which has no row for a host-registered dimension, so it answers empty for exactly these ids while the table it writes into holds them. `registeredInstance` now walks `DimensionRegistry::mDimensions` when the lookup comes back empty, which needs only a `std::hash` for `DimensionIdType` that the SDK does not supply for the derived type; the table, not the returned reference, decides whether a registration took, the built instance is held by a second owner until that is known so it is never destroyed under the chunk work `initializeDimension` started, and a registration the engine stored nowhere is placed in the table directly. The three `getOrCreateDimension` detours hand the engine `native::instanceRef`, a reference out of the same table, instead of calling `getDimension` after a build and returning the empty answer that told the teleport there is no such dimension. - **A custom dimension is allocated an id from 1000, not from 3.** Everything above was necessary and none of it was sufficient: with the instance built, placed in the registry and handed back by the detours, a teleport still left the player standing where they were. The number was the problem. `VanillaDimensions::Undefined()` sits just past the vanilla three, and the engine compares against it to mean no dimension, so a dimension holding that number is read as absent by every path that makes the comparison: `DimensionRegistry::registerDimension` stored nothing for it, `getDimension` came back empty, and `TeleportCommand::computeTarget` dropped the `ChangeDimensionRequest`. Writing blocks through a `BlockSource` taken straight out of the registry never makes that comparison, which is why the id looked usable for everything except entering it. Ids are now allocated from `native::firstCustomDimensionId`, 1000, which is where the engine's own script-api registration allocates from and where the ids in a save written by MoreDimensions 0.14 come from. A `dimension_config.json` entry below that floor keeps its name and payload and is reallocated on the next registration, with a line saying that anything built under the old number stays in the save and will not appear in the new dimension. - **The engine registers its own dimensions again, and this host no longer puts one into the registry by hand.** Entering a dimension registered that way fastfailed the server on a chunk worker thread with `FAST_FAIL_GUARD_ICALL_CHECK_FAILURE`, an indirect call through a pointer that was never set: `DimensionRegistry::registerDimension` is the engine's own entry point and does more than store a pointer, so a `Dimension` placed beside it is one the engine never finished wiring. A registration the engine declines is now reported as a failed registration and the dimension is unavailable, which is a server that says so instead of one that crashes minutes later. For the same reason the three `getOrCreateDimension` detours call `origin` first and build only when it comes back with nothing: the evidence that the engine could not create these dimensions was gathered while they were numbered 3, where every engine path refuses on principle. - **`getDimension` answers for a dimension this host registered.** It is a plain lookup and every part of the engine uses it, and it resolves through the engine name table, which has no row for a dimension registered from outside: it answered empty for one that was registered and running, so whatever asked walked into nothing. That is the shape of every crash in this series, on the tick and on the chunk workers alike, and the thing that proves it is a void dimension crashing exactly like one with terrain, at the same instruction, with no generator of the engine's involved at all. `DimensionManager:: getDimension` and `Level::getDimension` now fall back to the host's ledger after the engine's own answer, the same order the `getOrCreateDimension` detours use, reading the ledger directly so a hook cannot call back into itself. - **The engine's own terrain generators are used again, which is what an overworld, nether or end template asks for.** They had been replaced with a void carrying the same sky, because entering such a dimension took the server down every time it was tried. That reason was wrong: the fault was this host destroying a `Dimension` the engine was still using, a void dimension died at the same instruction with no generator involved, and it is fixed. What the fallback cost meanwhile was the whole point of those three templates, which arrived as empty voids. `engine_terrain: 0b` in a dimension's terrain turns the engine generator off again, for an operator who meets a fault in engine code this host cannot repair and still needs the dimension to open. A dimension that generated void chunks under the old default keeps them; only ground it has not reached yet comes out as terrain, so a test world is worth recreating. - **The end and nether generators are handed their own biome.** Both were given the level's biome override, which is what vanilla passes and which on a normal level is empty, so the lookup answered with nothing and the generator kept it. `OverworldGeneratorMultinoise` builds its own biome source and does not care; these two are each written for one biome. A server generating end terrain read a pointer of all ones inside the column loop, at the y the registers put at the top of the dimension, which is what a biome that was never there looks like once something walks it. The vanilla name is used when the level names nothing, and when neither is in the registry the dimension is generated as a void with a line saying so, rather than handing the generator nothing. - **A dimension keeps the id it already holds.** The suggestion stepped over every id in the caller's ledger, including the dimension's own: one recorded as 1000 was handed 1001, the record was corrected to 1001, and the next boot handed it 1000 again. It changed id on every start, and the chunks it had written stayed behind under the number it no longer had. The id from the record is now preferred when nothing else holds it. - **A dimension whose terrain is an engine generator gets that generator's own height.** These generators are not parameterised by the dimension they are handed: `TheEndGenerator` carries a fixed 16x16x128 block buffer and each of them indexes columns from the bottom of the dimension it was written for. A custom end dimension running from -64 instead of 0 had it write past what it allocated, and the server died on the tick after a player entered, with the end sky already drawn and no blocks in it. `spec::dimensionHeightOf` now answers with the generator's range for a native terrain, so the `Dimension`, the definition and the height check all get the same number, and says so in the log when it differs from what the spec asked for. A spec's own height still governs terrain that comes from this host. - **A generator is told about structure sets only when its structure features went in.** `ChunkGeneratorStructureState::createNormal` fills `mPossibleStructures` from the level's structure sets, and placing one means looking the matching feature up in the generator's own `StructureFeatureRegistry`. That registry is filled by three engine functions this host reaches by symbol, and on 26.40 they are not there: the generator was told about every structure set in the game while its feature registry stayed empty, and the first tick after a player entered called through a pointer nothing had set. With no features the state is now `createFlat` with an empty list, which says there are no structures to place, and the log says the dimension generates without structures. - **The factory closure builds the dimension with the id the engine's definition holds, not the number in `dimension_config.json`.** With the closure finally being called, its first build of a boot took `shared->id` from the record, which is the previous boot's answer; the engine had allocated a different number, so the object said 1001 while it was registered under 1000, the read-back reported 1001, the record was "corrected" to 1001, and the first player to ask had a second instance built and registered under 1001. The level tick died with two instances for one name. The closure now asks the definition group first, which is what the registration is happening against, and `buildAndRegister` refuses to register an object under a key that is not its own id. - **The host's factory closure is bound after the engine registers its own, so the dimension the engine builds is the host's class.** Every build so far has logged "built through the factory" and never the line the closure itself prints when it runs, which means `DimensionFactory::create` was not finding it: `_registerCustomDimensionWithFactory`, called after the closure was put in the map, registers the engine's own factory for the name over whatever is there, and what that built for the definition was a vanilla-class dimension with the definition's generator, never `SpecDimension`. That dimension was registered under the host's id, handed to a player, and fastfailed a chunk worker a third of a second later. The closure is now bound again right after that call, and the "is building" line in the log is what proves the host's class is the one being built. On 26.20 `serverRegisterCustomDimension` used the closure already in the map, which is why the previous host never had to do this. - **The default bottom of a custom dimension is the vanilla bottom, -64, not -512.** The -512 was a workaround: a client with no definition of its own to go by fell back to the largest possible world and requested subchunks -32..-24, which a server validating against -64 refused. With the definition now carrying the height that fallback has nothing left to do, and the workaround had a bill of its own: a dimension 52 subchunks tall is outside anything the engine ships, and across every crash on entering one the height was the single thing about the dimension that never varied. A spec may still ask for more; nothing forbids it and nothing vouches for it. - **The engine's dimension definition is given the height and generator the dimension is actually built with.** `_registerCustomDimensionWithDimensionDefinitionGroup` takes a name and an id and nothing else, so the definition carried the engine's defaults while the `Dimension` was constructed from the stored spec, and the two are read by different parts of the engine. A server that entered such a dimension died on a chunk worker a third of a second later, every time, on an indirect call through a value that is not a function, which is what a shape written into an allocation sized for a different shape looks like once something walks it. The definition entry is now reached through the engine's own iterator, checked against the id just registered so the fields are known to be where the header says, written, and read back; both the engine's original values and the spec's are logged either way. - **A custom dimension no longer pretends to be the overworld to upgrade a chunk.** `SpecDimension` implemented `levelChunkNeedsUpgrade`, `upgradeLevelChunk`, `fixWallChunk` and `_upgradeOldLimboEntity` by `reinterpret_cast`-ing itself to `OverworldDimension` and calling that class's exported thunks, on the reasoning that `OverworldDimension` declares no data member and the two layouts therefore coincide. With the registration finally working end to end, a server fastfailed on a chunk worker inside those bodies a third of a second after a player entered the dimension, on `FAST_FAIL_GUARD_ICALL_CHECK_FAILURE`: an indirect call through a value that is not a function. Whatever those bodies read at that offset, it is not what this object holds there. The four now do nothing, which is also the right answer on its own terms: these upgrade a chunk written by an older BDS, and a dimension of this kind is created by the version that is running, so every chunk in it was written by that version. The `WorldGenerator` that adds vanilla structure features through resolved symbols is untouched, but each of its three paths now logs a line, so the next report says whether it ran. - **The dimension factory hands back the instance in use instead of building a second one.** The closure this host puts in `DimensionFactory::mFactoryMap` is called by the engine too, on paths the host never sees and without a line in the log, and it built a new `Dimension` every time; whatever the engine then did with it ended in an assignment over the object the level, the players and the chunk threads were holding. It now returns the live instance when there is one, which makes that assignment a no-op, and it logs every call, so the log shows who is asking for a dimension to be built. - **A dimension this host registered is built once, because the host remembers it.** This was the fault behind every crash in this series. `DimensionManager::getDimension` resolves a `DimensionType` through the engine name table, and nothing exported writes a row there for a dimension registered from outside, so it answers empty for one that is registered and running. With no memory of its own, the host read that as "not registered" on every resolution and built another, and `DimensionRegistry::registerDimension` assigns into its map: each new instance replaced the one the level, the players and the chunk threads were holding, and a chunk worker fastfailed on a call through a pointer inside the destroyed object. A live server built one dimension six times in three seconds, then began logging a refusal thirty times a second until it died. The instance `registerDimension` returns is now kept in a host-side ledger of `WeakRef<Dimension>` by id, consulted right after the engine's own lookup and dropped when the dimension is retired or the engine lets it go. - **The registry table is no longer read for an answer, only reported.** It is reached through a header-declared offset into a private field, and the first server to print it read two entries with ids 59024 and 0, which the engine's dimension table cannot contain: that offset does not describe the running binary, so a `Dimension*` taken out of it is a pointer into whatever the memory really is. Every decision is back on `DimensionManager::getDimension`, on what `registerDimension` hands back and on the host's own ledger, and the field is not touched at all any more. - **A dimension is registered with the engine under `namespace:name`.** The engine's own registration path takes a qualified name, and MoreDimensions, the only implementation known to work on this generation, rejects anything else before it reaches the engine. A bare name is now qualified with `pier:` at the boundary that talks to the engine, which is also the key the factory map uses because `DimensionFactory::create` is called with the name the engine knows. The ledger, `dimension_config.json` and every mod-facing call keep the name the caller chose. A dimension already registered under a bare name is a new dimension under the qualified one and gets a new id. - **An id already spoken for in `dimension_config.json` is never suggested to the engine.** The suggestion walked the definition group, which holds only what the current boot has registered so far, so a dimension named in the config but not yet registered was invisible and its number could be handed to another dimension. A collision already in the config now names both sides and says that the chunks of the loser are not reachable. ### Added - **`service_caller`: a provider can ask who is calling it.** A `PierServiceCb` receives a request and nothing about its sender, so a provider keying anything on a name inside the request was trusting the request. The new slot, appended and struct_size-gated (the ABI stays at v2), sinks the manifest name of the mod whose `service_call` is on the stack; nested calls report the innermost one, and outside a callback or for a call made without a handle the sink is not called. `levilamina::service::caller()` is the Rust side. The first consumer is the RSW permission manager, which binds a wire owner to the mod that first announced it and refuses the name from any other mod. [/MD]
[MD] Terrain packs replace the declarative `layers` and `noise` terrains, and the ABI moves to v2. Nothing in this section had shipped in a tagged release, which is why the removals below are removals and not deprecations. ### Changed - **ABI v2.** `PIER_ABI_VERSION` and `PIER_ABI_MIN_SUPPORTED` are both 2. The four slots retired in 26.20.3, `md_add_simple_dimension`, `md_add_plot_dimension`, `md_set_plot_grid` and `md_clear_plot_grid`, are deleted from `PierApi`; every slot after them moves, so a mod built against v1 is refused at load with the version range in the message. `tools/abi-v1.slots` was re-blessed for v2. - **Three terrain kinds.** `md_add_dimension` serves `terrain:{kind:"native"}` only, now with `generator:"flat"` and `"void"` beside the three vanilla worlds and a `biome` for the void. A `layers` or `noise` terrain is refused with a pointer to the pack tool; `pier-pack from-layers` converts an old layers spec into a template source. - **Dimension spec.** The stored form of a pack terrain is `terrain:{kind, pack, sha256, params, roles}`: the config path, the binary's hash at registration, every bound parameter and the role overrides, so the terrain is regenerable from the save alone and a changed binary is refused rather than mixed in. ### Added - **`md_add_dimension_pack(name, config_path, spec_snbt)`.** Registers a dimension from a terrain pack: a directory with a config file and a binary. The host reads four keys of the config (`pier_terrain`, `type`, `binary`, `sha256`), compares the spec's terrain kind with the config's type and the binary's magic, hashes the binary against the config, binds the parameters by kind (free, fixed, choice, derived), checks the pack's constraints, and only then stores the spec. Refusals return one of the `PIER_PACK_*` codes, all negative; a dimension id is never negative. - **`md_pack_inspect(config_path, ctx, sink)`.** What a pack asks for as one JSON document, the same shape `pier-pack inspect` prints, so a mod builds its form from the pack's own parameter list without a parser of the binary. - **Template packs (PIERTPL).** A fixed range with parameters and cells whose data is the same: zones along each axis with expression lengths, a block stack per combined zone, constraints, and 3D structures as a CSG graph (box, cylinder, wedge, translate, rotate, mirror, repeat, union, difference, intersect, shell, paint, choose by parameter or cell hash, voxel blobs with keep and air cells) picked per cell with weights and turns. A CONF section registers the cell grid for the confinement rules from the same mount that produced the terrain. The plot world is the acceptance fixture and is compared cell by cell with a line-for-line port of the 26.20.2 `PlotGenerator`. - **Volume packs (PIERVOL).** Java's density function graph: 31 operators including splines, shifted noise, the caches, interpolation, end islands, the 1.18 blended noise and the weird scaled sampler; the multi-noise biome parameter list; and the surface rule subset (block, sequence, condition, biome, noise_threshold, vertical_gradient, y_above, water, temperature, steep, not, hole, above_preliminary_surface, stone_depth). A pack stores noise parameters, never tables: the host builds the tables from the world seed with Java's Xoroshiro128++ or legacy random source, so one pack serves any seed. `pier-pack from-datapack` assembles a source from a Java datapack directory. - **`tools/pier-pack`.** `build`, `inspect`, `hash`, `from-layers`, `from-datapack`, a Python reference generator for each pack kind, and tests that compile the engine-free pack layer under g++ and compare it with the reference cell by cell. - **Rust SDK.** `dimensions::add_dimension_pack`, `dimensions::pack_inspect`, `PackStatus` and `PackError`; `GeneratorType::spec_name` for the spelling a spec uses. The crate is `2.0.0`, since the four removed functions are a breaking change. - **`tools/migrate_dimension_config.py` no longer writes a `layers` terrain.** A void or a vanilla generator migrates in place as before; a pre-26.20.3 plot entry is left alone and the three steps to rebuild it as a template pack are printed, since the terrain of a plot world is a binary the host verifies by hash and cannot be written into the config file. ### Removed - `spec::Layers`, `spec::Noise`, `LayersGenerator` and `NoiseGenerator`, superseded by the two pack kinds; the Rust SDK's `add_simple`, `add_plot`, `set_plot_grid`, `clear_plot_grid` and `PlotLayout`, superseded by `add_dimension_pack` and `pack_inspect`. ### Known limits - The liquid layer of a voxel blob is decoded but not yet written into the chunk's second block layer, so waterlogged blocks in a blob come out dry. - A volume pack has no aquifers, ore veins or bandlands, and the biome of a column is the one at its surface rather than a 3D field; `temperature` uses the biome's base temperature without the vanilla noise term; `sea_level` of the pack is used for the fluid fill but the dimension's own sea level stays at the sky's default. [/MD]
[MD] ## [26.32.0] - 2026-09-07 The BDS 1.26.32 release. Mojang's build now inlines a large part of what used to be a callable symbol, so LeviLamina 26.32 declares about a third fewer non-virtual functions than 26.20 did while virtual declarations are untouched. Nothing in the engine went away; the wrappers around it did. Where a wrapper is gone this release reaches the same data through the ECS accessor namespaces, a public member, or a surviving virtual, and where the reachable thing is narrower than the old one the documentation says so rather than keeping the old wording. The ABI stays at v1 and no slot is added or retired, so a mod built against 26.20.2 loads unchanged. One slot reports a narrower number, described below. ### Changed - **Requires BDS 1.26.32 and LeviLamina 26.32.0.** 26.20 is no longer supported. - **`profile_take`'s `chunk_blocks` bucket measures less than it did.** `LevelChunk::tickBlocks` is inlined away and has no address to detour, so the bucket now times the drain of a chunk's pending block-tick queues, scheduled and random, summed over every chunk. Work a chunk does around that drain falls outside it, so the figure is a floor on block ticking. `calls` counts queue drains, and a ticking chunk contributes two. - **The crossbow-firework projectile guard asks at the release instead.** `CrossbowItem::_shootFirework` is inlined away. The guard hooks the virtual release and reads the charged item, so an arrow-loaded crossbow still costs one decision and not two. - **`DimensionRule::PistonCrossPlot` and `EntityCrossPlot` are now `PistonCrossCell` and `EntityCrossCell`** in pier-rs, matching the ABI names since 26.20.3. The old spellings stay as deprecated constants with the same values, so an existing mod compiles. - The Rust binding moves to pier-rs 1.2.0 / pier-sys-rs 26.32.0. ### Added - **A C++ tutorial beside the Rust one, and a C++ example with four build files.** `docs/cpp/` covers what the Rust wrappers do for you and you now do yourself: the entry point, the capability check against `struct_size`, `PierStr`, and the bool-plus-out error convention. `examples/hello-pier-cpp` is a mod that registers one command, with MSVC, clang-cl, CMake and xmake files producing the same DLL from the same source. - **`PIER_MAIN_EXPORT` in abi.h.** The header named the entry symbol without saying how to export it, so a C++ mod compiled, linked and was refused at load. An ELF build exports it by default, which is why this took a Windows server to surface. - `pier-probe` moved from `examples/` to `tools/`. It is a diagnostic, not a thing to learn from; `examples/` now holds the two mods that are. - **`md_retire_dimension(name)`.** Drops a custom dimension from `dimension_config.json`, from the host's tables and from the dimension factory, so the next boot does not register it and `md_list_dimensions` stops reporting it. The ABI stays at v1: the slot is appended and no existing one moves. It is not a delete. The chunks stay in the save, the engine keeps the dimension it built for this session, and a player standing in it is not moved. Nor is the id handed back: registering the retired name later is a new dimension with a new id. That leaves the old chunks orphaned and costs disk, and the alternative is worse, because reusing the number points a new dimension at the terrain of the old one and nothing about that is recoverable. - **`sky.time` in the dimension spec.** An optional tick of day, 0..23999, beside `skylight` and `weather`. The dimension is held at that tick and every other dimension keeps following the level clock. It sits in the spec because it describes the sky and not the terrain, the same as its two neighbours; the alternatives on the mod side are `set_time`, which moves everyone's world, and rewriting SetTime per player, which needs a packet hook and fights any other plugin that also manages time. A nether or end client sky has no day cycle to begin with, so the field only changes an overworld sky. - **`verb` in the ExecutingCommandEvent payload.** The canonical command name with aliases resolved, read from CommandRegistry. Splitting the raw line on whitespace gives a different answer: `/w`, `/tell` and `/msg` are one command behind three names and a gate keyed on the first word refuses one spelling while the other two pass. The alias table is not visible outside the registry. `/execute ... run <command>` still reports `execute`, because the inner command is parsed after this event has been decided. ### Verified on a real server `tools/pier-probe` walked the whole table on BDS 1.26.32.2 with LeviLamina 26.32.2: 225 probes, 219 answered, none refused, none threw. The six absent slots are the `client_*` family, which a server build does not fill. `server_info_str[1]` reported protocol 1001, which is the number the server's own startup banner prints, so the replacement for the inlined-away `NetworkProtocolVersion` reads the same value the engine does rather than a plausible one. ### Fixed after the first run on a real server - **`game_rule_get` and `game_rule_set` answered for `pvp` and nothing else.** `GameRules::nameToGameRuleIndex` is inlined away in 26.32 and the host walks `GameRule::mName` instead. That field is spelled the way `GameRulesIndex` is, `showCoordinates` rather than `showcoordinates`, while `/gamerule` and every existing caller write it in lower case, so an exact comparison matched only the one rule name with no case to get wrong. The match folds ASCII case now. ### Capabilities that now report failure Every one of these is a slot that still exists and still returns; it answers false or nothing instead of a value. The engine function behind it is inlined away with no field or component left to read it from, or it is compiled only on the client platform. A mod that treats a false from one of these as fatal will refuse to start, so the list is here rather than only in the source. - `actor_property`: IS_IN_LAVA, IS_PERSISTENT, HAS_TOTEM. - `actor_action`: SET_VARIANT, SET_MARK_VARIANT, SET_SKIN_ID. SET_SCORE_TAG still works: the engine exports the string specialization of the synched-data write and not the integer one. - `player_property`: LUCK, IS_EMOTING, IS_IN_RAID, HAS_RESPAWN_POSITION, DIRECTION, IS_USING_ITEM. - `player_action`: RESEND_ALL_CHUNKS, REGISTER_TRACKED_BOSS, UNREGISTER_TRACKED_BOSS. The last two are client-platform only. - Attribute writes through `player_attribute_set`. Writing the field directly would skip the listener pass that syncs the value, so the attribute would move on the server and not on screen. - `item_property`: HAS_DURABILITY, IS_FIRE_RESISTANT, IS_MUSIC_DISC, IS_OFFHAND, IS_UNBREAKABLE. `item_string`: EFFECT_NAME. - `item_op`: SET_CAN_DESTROY, SET_CAN_PLACE_ON. The field holds resolved block pointers beside a hash the engine compares against, and a wrong hash stops the restriction from applying without saying so. - `block_property`: IS_UNBREAKABLE. - Actor copying in `world_edit`: the id-remapping helper needed to avoid colliding with the source actor is gone. - Custom dimensions in full. `md_*` slots that create one report failure; see the note under Changed. Two more answer a narrower question than before rather than failing: `profile_take`'s `chunk_blocks` bucket, described above, and the potion effect list, which no longer distinguishes an infinite duration. ### Fixed - **Comments that were not true.** `SpecDimension.cpp` said a file that was still in the tree and still compiling had been deleted, and both it and `spec_dimension.h` said the mod-side record and `dimension_config.json` hold the same bytes. The manager writes the tag back out with `SnbtFormat::Minimize`, so the equality that holds is structural. - **Retired source was still in the tree.** `SimpleCustomDimension` and the four `plot/` files are cut; `SpecDimension` and `gen/` replace them. ### Requirements | | | |---|---| | Bedrock Dedicated Server | 1.26.32 | | LeviLamina | 26.32.0 | | Platform | Windows x64 | [/MD]
[MD] ## [26.20.2] - 2026-09-05 A performance and correctness release for BDS 1.26.20 / LeviLamina 26.20.4. The ABI stays at v1: every new slot is appended and gated by `struct_size`, so a mod built against 26.20.1 loads unchanged and a mod built against 26.20.2 still loads on a 26.20.1 host, with the new slots reported as absent. The Rust binding moves to pier-rs 1.1.0 / pier-sys-rs 26.20.2. ### Fixed - **TPS and MSPT were the frame period.** `get_tick_delta_time` exposes `mTickDeltaTime`, the wall-clock period of the last frame including the idle sleep BDS inserts to hold 20 Hz. Its reciprocal read above 20 on about half the frames of an idle server, stayed at 20 while the world was frozen, disagreed with `/tick warp` in both directions, and `tick_delta_time * 1000` reported as MSPT read 50 on an idle server. New `get_tps` and `get_mspt` count the `Level::tick` calls that really run and time them, over a 1..60 s window (`TickStats.cpp`). `Server::tps()` reads the new slot; `Server::mspt()` is new. - **Profiler `level_tick` under warp.** The detour wrapped the whole warp loop, so `/tick warp 5` made the per-tick average five times too large. It now sits innermost. - **Text packets.** `player_send_message_typed` sent a `MessageOnly` body under the Chat, Whisper and Translate types; the bodies now match the type on the wire. - **Inventory-UI drops.** `PlayerDropItemEvent` recognized any single container action as a drop and cancelled creative pickups and crafting remainders with it; a drop now also needs the world-interaction action the client sends. - **`/tick step` stalls.** A frozen world ran every queued step tick in one frame; 1200 steps were one frame a minute long. At most 100 run per frame, the rest carry over. - **SNBT floats.** `snbtNum` on a floating point value could emit `1e+21`, `inf` or `nan`, which the parser on the far side refuses, losing every later field. - **Unload race.** `ModHost::unload` read the callback counter and then called `FreeLibrary`; a callback starting on another thread in between ran in unmapped code. An `unloading` gate now closes first and every asynchronous dispatch site tests it. - **Dimension rule spawn.** With natural spawning fully denied the engine still built the mob and despawned it; it is refused before the spawn. - **Rust `Event::cancel`** cloned and reserialized the whole payload; it writes back only the keys it touched, which the host diff already supported. - **`ProjectileEvent`** declared its detour on `BedrockSpawner`, which declares no override; it is declared on `Spawner`, whose symbol it hooks. ### Performance - **Packet interception filtered by id.** `packet_hook_register_ids` and `Packets::intercept_ids`: a packet no subscriber listed passes through before any lock, snapshot or address lookup. The unfiltered slot made every packet in a direction, chunk data included, pay one callback per subscriber. - **Bulk block reads and writes.** `scan_region` walks chunk by chunk and caches the SNBT per block state; `scan_region_indexed` reports a palette plus indices; `edit_fill_region` and `edit_set_blocks` resolve a spec once per call. Rust: `World::scan_indexed`, `fill_region`, `set_blocks`. - **Player selectors cached** to `ActorUniqueID` with a verified `fetchEntity`; a repeat lookup no longer scans every player and allocates a name per player. - **`ItemStack` reads Name, Count, Damage and `tag` locally** from a cached parse instead of crossing the ABI for each. - **Event write-back** diffs against the live tag and no longer reparses the snapshot; hook events hand over a static id string. - **Dimension rules and plot grids** answer from atomics on the hooked hot paths (liquid flow, fire, spawning, pistons, explosions, riding, `Actor::move`). - **KvDb** locks per database instead of one lock for every database of every mod. - **Sidebar** sends only the rows that changed; an unchanged refresh sends nothing. - **`container_get_items`** reads a whole container in one call (`Container::items`). - **`level_set_biome`** looks a chunk up once per chunk instead of once per column. - **Legacy `schedule` and money listeners** resolve the owning module with one `GetModuleHandleExW` instead of one per hosted mod. ### Added - ABI slots (all appended): `get_tps`, `get_mspt`, `packet_hook_register_ids`, `scan_region_indexed`, `edit_fill_region`, `edit_set_blocks`, `container_get_items`; sink types `PierPaletteSink`, `PierCellSink`, `PierSlotSink`; struct `PierBlockCell`. [/MD]
后退
顶部 底部