bump-bds
Skill766 starsChanged 34 days agoApache-2.0View on GitHub
Update Endstone to support a new Bedrock Dedicated Server (BDS) version - regenerate the symbol offset tables and port src/bedrock to the new ABI. Use when bumping the supported BDS version (e.g. "add support for BDS 1.26.x", "bump the BDS version").
- Installs packages
In this repository8 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- add-conan-recipeSkill
- build-ida-dbSkill
- fix-issueSkill
- locate-functionSkill
- reconcile-headersSkill
- releaseSkill
- update-signaturesSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
23 rules. About 0 are common, 6 are distinctive, and 17 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈6
Distinctive · estimate
This author's own choices. Worth a look.
17
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 8
Every bump is the same two jobs:
- Very specific: line 44
Both artifacts are NDA-protected: Never copy header bodies, class definitions, full member layouts, PDB dumps, symbol listings, or other …
- Very specific: line 215
Structural refactors: - when BDS introduces a base class, mirror it (add the base header, re-parent, move shared members down).
- Very specific: line 224
After the edit: update the mangled name in scripts/configs/{windows,linux}.toml, re-run the dumper, and update any affected hook in …
- Distinctive: line 314
No header diff. You have:
- Very specific: line 325
Run everything through the ida-pro py_eval (see [[reference_idalib_mcp_quirks]]).
- Very specific: line 372
Re-cut a stale / wrong byte pattern: Prefer a prologue pattern (the push sequence + sub rsp) over a call-site one.
- Very specific: line 392
Same RVA != same function across versions: Do *not* identify the new function by reading what name sits at that RVA in the *old* DB - code …
- Very specific: line 417
Instead fix the one verified entry in src/bedrock/symbols/<platform>.h
- Very specific: line 568
When an Endstone event fed by a Connector/Publisher stops firing, clear or convict the BDS side before touching src/bedrock/.
- Very specific: line 653
Check what Endstone actually accesses before padding the unused middle (grep the *Ref/wrapper that exposes the type) - if it reads a tail …
- Very specific: line 673
Rule out your own recent edits before blaming BDS: If a hook's declared return type or parameters changed, a corrupted this produces the …
- Very specific: line 692
Do not reach for a debugger - bisect with fprintf(stderr, ...) + fflush.
- Distinctive: line 710
When you find one, go looking for its siblings in related classes before the next crash finds them for you.
- Distinctive: line 757
Uniform-delta check over the whole object: If *every* store from the first divergence to the end moved by exactly the same delta, there is …
- Distinctive: line 900
Only a class that fails all three - held solely through a pointer, never a variant alternative nor a by-value member of one - is genuinely …
- Very specific: line 919
Sweep the RAW
static_assert(sizeof(X) == N)form too: Not every guard uses BEDROCK_STATIC_ASSERT_SIZE. - Very specific: line 1180
When the flat model's leading member is already 8-aligned (a polymorphic TypedClientNetId, a struct holding one 8-byte seed) point 16's …
- Very specific: line 1338
Only the size driver (largest K) needs a byte-exact body.
- Distinctive: line 1368
When you cannot name a type/signature precisely, placeholder it - but keep the
- Very specific: line 1400
When the new release is a hotfix off the current one, lief alone can *prove* the bump is ABI-free in minutes - no IDA database, no header …
- Very specific: line 1479
Do not skip the source-side bump because the offsets moved uniformly: shared_constants.h (PatchVersion, and NetworkProtocolVersion if it …
- Very specific: line 1518
When a function has no usable symbol at all (an overload dropped from the PDB publics, or a lambda) - resolve a *callee* instead: …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Bump Endstone to a new BDS version
Every bump is the same two jobs:
- Regenerate the symbol offset tables - which hook resolves to which
address (
src/bedrock/symbols/{windows,linux}.h). - Port
src/bedrock/to the new ABI - fix the signatures, vtable orders and member layouts that changed, so those offsets land on the right code and memory is read at the right offsets.
How you discover what changed (and the new signatures job 1 needs) depends on your reference material. Pick the scenario - the rest of the skill is split along it:
- Scenario A - full (you have
bedrock-headers). The dwarf2cpp header diff tells you exactly what changed and why. The canonical path; use it whenever headers for the target version exist. -> Scenario A below. - Scenario B - limited (no headers; only IDA databases). You have a Linux BDS database (RTTI present) and a Windows BDS database, and maybe a stale PDB - but no header diff. You reverse-engineer each ABI change directly from the binaries, driven by symbol misses (build) and runtime crashes. -> Scenario B below.
Both scenarios share The symbol pipeline, Editing src/bedrock correctly, Finish, and most Gotchas. A real bump is often mostly A with a few B spot-checks (confirm a vtable against the binary), or runs as B until headers land and then finishes as A.
NDA boundary (read first)
This workflow may use two private Mojang-derived artifacts:
bedrock-headers- C++ headers reconstructed from BDS binaries. Required for Scenario A; absent by definition in Scenario B.bedrock_server.pdb- useful for Windows symbol resolution when available and current, but not published for every release (and a stale one is a trap - see Scenario B).
Both artifacts are NDA-protected. Never copy header bodies, class
definitions, full member layouts, PDB dumps, symbol listings, or other private
artifact contents into the public endstone repo, its commits, PRs, issues,
logs, or this skill. Endstone's src/bedrock/ is a hand-written, minimal
reimplementation - only what Endstone needs, in Endstone's own naming - which is
the DMCA-safe form. Treat headers, PDBs, generated dumps, decompiler output, and
diffs as private working references only.
Orchestration - never wait idle
Building the two IDA databases is the long pole (hours each, multi-GB). Almost nothing else in a bump needs one, so the database must never be the thing you are waiting on.
- DO launch both databases first, via
build-ida-db- the opening action of a Scenario-B bump, not a step you reach later. - DO run the entire non-IDA half while they analyse.
lief+capstoneon the shipped binaries settle, with no database: the protocol version; the per-class vtable diff (Linux RTTI);sizeoffor every asserted class; whether a resolved offset is a real function start (.pdata/.eh_frame_hdrFDE); referenced string-set identity; and cutting, replaying and uniqueness-checking byte patterns. - DO order it: dumper triage -> non-IDA sweeps -> IDA. Triage names the broken entries, the sweeps clear or convict most of them, and only the string-less locates genuinely need a database.
- DO switch to the database the moment it is ready - it outranks your own
scanners. The
lief/capstonepath is a stopgap for the hours the analysis is running, not a preferred technique. Once a platform's.i64exists, take xrefs, function boundaries, call graphs and identity from IDA; fall back to a hand-rolled scan only for the platform whose database is still building, or for a whole-binary sweep IDA has no cheap equivalent of (the per-class RTTI vtable diff, the section-wide invariant counts). - DO NOT trust a hand-rolled scanner's negative result. It under-reports
silently and in ways that read like a real finding: a
lea-only scan misses themovups xmm0,[rip+x]form that materializes short string literals, and folding the ModRM byte into the disp32 capture matches nothing at all. A string that plainly exists but scans as unreferenced is a bug in the scanner until IDA says otherwise - ask the database before concluding a locate recipe went stale. - DO validate any scanner of your own against the PREVIOUS binary before trusting it - every committed pattern must resolve to its known-good offset there. Until it reproduces the dumper exactly, its verdicts on the new binary mean nothing.
- DO anchor an identity claim on a self-naming marker string (a trace/profiler
literal like
<Class>::<method>) referenced from exactly one site in both versions. A windowed string-set comparison overruns into neighbouring functions and will convict a correct offset. - DO fan the per-symbol locates out to parallel agents once the databases are up - they are independent. There is ONE idalib worker, so agents driving idalib serialise: fan out the reading and decision work, not the idalib calls.
- DO NOT block on the databases to start the port. The vtable and member-layout work is the larger half of a bump and is entirely RTTI-driven on Linux.
The symbol pipeline (shared)
How it works
scripts/configs/{windows,linux}.toml signature configs, hand-maintained
| scripts/dump_symbols.py
v
src/bedrock/symbols/{windows,linux}.h std::array of name -> offset (committed)src/bedrock/symbol.h get_symbol() looks a symbol up by __FUNCDNAME__ - the
mangled name of Endstone's own declaration in src/bedrock/. The symbol-table
key IS the signature of Endstone's reimplementation. Unresolved symbols are
written as 0 and dropped by the dumper (that hook is disabled; the build still
succeeds, unless any TU actually consumes the missing name - then consteval
get_symbol() throws at compile time).
Prerequisites
uv- runsdump_symbols.py(PEP 723 inline deps, no manual install).pdbtool-cargo install pdbtool(Microsoft pdb-rs). Reads a Windows PDB when one is available and current.- The target version published in
EndstoneMC/bedrock-server-data(the Linux path downloads the binary from it - check itsversions.json). - Scenario A only:
bedrock-headersfor the target version (must remain private). - Optional: the Windows BDS PDB (
bedrock_server.pdb) for the target version. Confirm its version matches the exe before trusting--pdb(a stale PDB silently mis-resolves moved symbols - see Scenario B).
Procedure
- Branch off the current release branch (e.g.
v0.11):git checkout -b feat/<NN.NN>-support(naming followsfeat/26.10-support). - Bump the config versions - set
version = "<X.Y.Z>"in bothscripts/configs/windows.tomlandscripts/configs/linux.toml, using the 3-component release string from bedrock-server-dataversions.json. - Regenerate (run in the background, 25 s to a few minutes):
- Windows:
uv run --script scripts/dump_symbols.py scripts/configs/windows.toml --pdb <path>/bedrock_server.pdb - Linux:
uv run --script scripts/dump_symbols.py scripts/configs/linux.toml - No (current) Windows PDB? Drop
--pdband rely on the byte-patternfallback per entry; resolve the gaps the Scenario-B way.
- Windows:
- Triage the failures - this tells you which symbols broke (the input to
the porting work). How you find the fix is per-scenario.
- Windows (PDB by name, then byte pattern): each entry is looked up by
mangled
namein the PDB; entries the PDB has no public record for (lambdas, function-local statics) fall back to scanning the entry'spattern. A miss means both failed - the mangled name is gone (MSVC encodes the full signature incl. return type, const-ness and access) and the byte pattern no longer matches. A PDB hit is name-verified; a fallback hit ("Found signature (fallback)") is only pattern-verified, like Linux. - Linux (byte-pattern scan): a miss = the
patterninconfigs/linux.tomlno longer matches. The function usually still exists - the pattern went stale. A Linux hit is a pattern match labelled with the config name; it is not name-verified. - Failed on both -> real signature/API change.
- Windows only -> the mangled name changed: signature, return type,
const-ness or access. Itanium omits the return type, so a pure return-type
change leaves the Linux name intact. A const/access change is fixed in
Endstone's
src/bedrock/declaration (__FUNCDNAME__derives from it), not the config alone. - Linux only -> stale byte pattern; re-extract it (see Gotchas).
- A Windows fallback hit for an ordinary named function means the PDB no
longer has that name. The function may have been split, renamed or inlined,
so a pattern under the old name can land on a different function. Find what
replaced it and hook that instead (1.26.51
_loadMapData, #537).
- Windows (PDB by name, then byte pattern): each entry is looked up by
mangled
Editing src/bedrock correctly (shared)
Whatever told you what changed, the edit obeys the same rules. ABI edits are easy to get subtly wrong - a wrong vtable slot or member offset corrupts memory silently, caught by neither a compile nor a PR review. Build and test iteratively; never batch many unverified ABI edits.
- Function signatures (especially hooked /
ENDSTONE_HOOK) - parameter types, const/ref, return type must match BDS exactly, or__FUNCDNAME__stops matching the symbol. - Virtual functions - the vtable order must match BDS. An added / removed /
reordered virtual shifts every slot below it; mirror the new order (use
= 0placeholders for virtuals Endstone does not implement). Only the slot count matters for ABI - onevirtual void <name>() = 0;is one slot whatever its signature. - Members - type, order and size must match for layout; member names
stay Endstone's own (
lower_case_), never Mojang's. Width-ambiguous integers: bedrock-headers/Linux buildunsigned longis 64-bit, Windows (LLP64) 32-bit - portunsigned longasstd::uint64_t(64-bit on both targets). - The first member after a base is per-ABI. Itanium allocates derived
members from
dsize(base), MSVC fromsizeof(base), so a first member with alignment < 8 lands at 44 on Linux and 48 on Windows underPacket(48/44). Mirror whatever BDS's own class starts with - an inline scalar shifts the same way, an 8-aligned sub-object does not.clang++ --target=x86_64-pc-linux-gnu -Xclang -fdump-record-layoutson a self-contained repro printsdsizeand every offset; run it for both targets rather than reasoning about it. - Template arguments - a class template's default arguments are part of
its declaration: copy them verbatim, never guess (e.g.
brstd::bitset's word-type defaults tounsigned int). Never drop an explicit argument to lean on a default; spell every argument the actual instantiation spells (apply the int-width rule to those too). - One type per corresponding file - a needed BDS type Endstone lacks goes in
its own
src/bedrock/header mirroring the BDS file (snake_case path), then#included - do not paste a foreign definition inline. A forward declaration used across many headers goes insrc/bedrock/forward.h(alphabetical); for a heavy include chain, forward-declare and use the type incomplete (fine for pointers, references, and container value types). - Every header must be self-contained. A sweep that adds one
#includeto an events/shard header can re-order the whole chain and expose headers that were silently borrowing a transitive include - the symptom isno template named 'X'plus a cascade ofstatic_assertsize failures in a file the sweep never touched. Include what you use, in the file that uses it. Verify with a one-line TU (#include "<the header>") compiled/Zs(-fsyntax-only) using flags lifted from the build log, not from the repo-rootcompile_commands.json, which goes stale and can miss defines (-DNOMINMAX,-DWIN32_LEAN_AND_MEAN). Sweep the whole sibling directory at once - latent cases cluster. - Structural refactors - when BDS introduces a base class, mirror it (add
the base header, re-parent, move shared members down). When BDS removes a
class,
git rmoncegrepconfirms nothing references it. Follow BDS structure; only the file name differs (snake_case). Keep it minimal. - Knowing the type vs placeholdering it. Scenario A: declare the real type - never a same-size stand-in. Scenario B: when you cannot name a type/signature precisely, use a documented placeholder (see Scenario B - Placeholders) - but the size / order / slot-count must still be exact.
After the edit: update the mangled name in scripts/configs/{windows,linux}.toml,
re-run the dumper, and update any affected hook in
src/endstone/runtime/bedrock_hooks/.
Scenario A - full port with bedrock-headers
The header diff is the source of truth: it lists every signature, vtable and member change. Work it stage by stage, then apply each via Editing src/bedrock correctly.
Source: the header diff
dwarf2cpp reconstructs C++ headers from a DWARF-bearing BDS build (the Android
build libminecraftpe.so carries DWARF; the Windows/Linux server binaries are
stripped). Output lands in bedrock-headers, one branch per BDS release
(android/r26_u1, android/r26_u2, ...).
dwarf2cpp <libminecraftpe.so> --base-dir <build-root> -o <out>(oruvx dwarf2cpp).- In
bedrock-headers:git checkout -b android/r<NN>_u<N>, place the output, commit. git diff android/r<prev> android/r<new>is the change set.
The actionable set
src/bedrock/ is ~655 hand-maintained headers - a small subset of BDS. Most of
a release diff (5000+ files) touches nothing Endstone declares. So:
actionable work = (changed headers) intersect (the 655 src/bedrock headers)
Match by normalized basename (lowercase, strip _ and -): bedrock-headers
Mob.h <-> Endstone mob.h; BlockSource.h <-> block_source.h.
Staged review order
Review the diff in stages - foundational types first, so later stages do not
rework. Scope: the handheld/ tree and the top-level src/base/ tree;
skip handheld/src-client/ (game client) and the other top-level src/
subtrees (account, external, gui - client / Xbox / third-party). Within
each stage, deep-dive only the intersection. (The first attempt used 3 coarse
stages; "handheld/src non-world" alone was 467 files / 44 intersecting - too
big. Use this finer split:)
src/base(top-level, not underhandheld/) - the sharedCorelibrary: foundational utilities and low-level types (BinaryStream, ...). Easy to miss because every staged path below lives underhandheld/while this tree is separate; a missed change here (e.g. a newBinaryStreamvirtual) silently shifts a vtable that Phase 1 can never flag.src-deps/SharedTypes- shared types and enumssrc/common/network- packets, network types, packet-id / disconnect enumssrc/common/server(incl.server/commands) - server and command systemsrc/common/entity- ECS componentssrc/common/{certificates,resources,scripting,platform,locale,gameplayhandlers,...}- remaining non-worldsrc/common/world/actorsrc/common/world/itemsrc/common/world/level/blocksrc/common/world/level/{dimension,biome}and remainingsrc/common/world/level/*(chunk, material, storage, level core)src/common/world/*- remaining world (attribute,effect,events,inventory,response, ...)src-depsother than SharedTypes (Certificates, VanillaComponents, ...), then anything else- Cross-validate - once every ABI change is in, re-review the whole
src/bedrock/diff against the bedrock-headers diff. Every edited function signature, vtable slot, member type/order, and structural change must trace to a concrete change ingit diff android/r<prev> android/r<new>. Reject anything not backed by the diff: no invented types, no guessed members, no hallucinated signatures, no "looks-right" edits. A change that cannot be matched to the header diff is wrong - revert or fix it. This stage exists because the porting stages, especially when parallelised across agents, can introduce plausible but unfounded edits - they must all be matched up.
Reading the diff: noise to skip
dwarf2cpp churn that is not a real BDS change:
- Versioned-namespace churn -
SharedTypes/v1_26_10/...becomesv1_26_20/...; most of that subtree's diff is just the version bump. - Template-instantiation churn -
SharedPtr.h/SharedCounterand similar enumerate concrete instantiations (CopperBlock<ThinFenceBlock>, ...). The set churns every release; Endstone uses its own templates - ignore. - File regrouping - dwarf2cpp regroups types into different generated files.
A file shown as fully deleted (e.g.
CommonTypes.h) often just means its types moved. Confirm a type is genuinely gone, not relocated. - Lambda source-location churn -
match<(lambda at .../Foo.cpp:47:3)>- line/column numbers shift every build. Pure noise. - Declaration reordering - declarations reordered within a file; the diff shows -/+ pairs of identical content moved.
Scenario B - limited port from the binaries (Linux RTTI + Windows DB)
No header diff. You have:
- a Linux BDS database - stripped of function names but RTTI is intact
(
_ZTV<len><Class>vtables,_ZTItypeinfo), so polymorphic classes, vtables, and Itanium-mangled names are recoverable; - a Windows BDS database - what Endstone actually hooks (and may carry partial symbols: some methods demangled even though ctors/vtables are not);
- a previous, named reference DB for both platforms (the last version, with PDB symbols) to diff against;
- possibly a stale PDB - treat with suspicion.
Run everything through the ida-pro py_eval (see [[reference_idalib_mcp_quirks]]);
note that in py_eval two top-level defs cannot call each other (exec scope) -
nest helpers in one function. find_bytes + py_eval xrefs stay responsive
when search_text / xrefs_to / make_signature time out on the busy DB.
The loop
Without a diff, work is driven by two signals, fixed one at a time (build/test between each - see Editing src/bedrock correctly):
- Symbol misses from the dumper (Phase 1 triage) -> Finding a new symbol / offset below.
- Runtime crashes / misbehaviour once it runs -> a vtable shift
(Detecting vtable changes) or a member-offset shift (Detecting data-member
layout changes). An AV in an accessor/
_get/_setControlBlock/unique_ptrderef means a field is read at the wrong offset; clean misbehaviour with no fault (e.g. a hook whose argument is garbage) often means a hook landed on the wrong function. Astd::_Throw_bad_variant_accessthrown from aScript<...>GameplayHandler::handleEvent*(event.visit(...)) is an event-variant drift (Detecting event-variant changes).
Finding a new symbol / offset without a header diff
- A string anchor is not referenced only by
lea- short literals arrive via SSE. A literal that fits astd::string's SSO buffer is materialized withmovups xmm0, [rip+disp32](0F 10 05 ..., no REX, two-byte opcode) and stored withmovups [reg], xmm0(0F 11 ...); longer ones come in 16-bytemovupschunks. A hand-rolled scanner that only matcheslea reg,[rip+x](48/4C 8D <modrm> disp32) reports such a string as having zero references, which reads exactly like "the string is dead" or "the recipe is stale" and sends you re-anchoring a recipe that was fine. Match the0F 10 05/0F 11forms too, and when a string that plainly exists scans as unreferenced, ask the database for its xrefs before rewriting the recipe. (1.26.51:%multiplayer.player.left,Resource Repository Async GroupandFailed to resolve block "all scanned as unreferenced for exactly this.) - Also mind the ModRM byte when hand-writing a rip-relative scanner. The
displacement starts after modrm, so the regex is
[REX] <opcode> [ %-5=] (disp32); folding modrm into the disp32 capture silently matches nothing. - Navigate by string anchor, not symbol. To locate an unnamed function:
take a string literal it references (an error/i18n key like
commands.setmaxplayers.success.lowerbound),find_bytesthe ASCII hex of the string,xrefto the referencing function, and read it. Diff it against the previous DB's named equivalent (e.g.SetMaxPlayersCommand::execute) to read off the new offsets/signature. Alwayslookup_funcsthe name first - the Windows DB's partial symbols may already have it. - Re-cut a stale / wrong byte pattern. Prefer a prologue pattern (the
pushsequence +sub rsp) over a call-site one; the match offset is then the function start. For a virtual, re-cut from the vtable, not a raw scan: find the class vtable (Linux RTTI_ZTV<len><Class>; Windows via the documented string -> ctor ->__vftablestore route), take the exact slot (mind the dtor-slot difference: Itanium 2 dtor slots, MSVC 1), read the prologue there, and wildcard only displacements/immediates. - Verify a pattern-resolved offset two ways, not one. (1) It must be a
function start -
ida_funcs.get_func(ea).start_ea == ea; an offset that lands mid-function is conclusively wrong. (2) Decompile it and confirm it is the intended function ([[feedback_decompile_to_confirm]]) - same-named overloads (sendPacket(string&, Reliability, Compressibility)vssendPacket(string&, Packet&, ...)) have different bodies; match the body to what your hook expects. A stale prologue pattern does not just miss - it can silently match a different function with the old shape (this bitBatchedNetworkPeer::sendPacketat 1.26.32: its codegen addedpush r12..r15, so the old55 56 57 53 ...pattern collided with a packet-trace overload). - Sweeping/verifying the whole table: compare the committed offset's body
against the previous version's named function - never against a name. Two
traps that each produce a false verdict (both bit a real 1.26.32 sweep):
- Same RVA != same function across versions. Do not identify the new
function by reading what name sits at that RVA in the old DB - code
relocates every release, so the old DB's
0x8e8a00(changeToValueType) says nothing about the new DB's0x8e8a00(which was the correctRepositorySources::initializePackSource). Decompile the new offset's body and match its behaviour to the old named function: distinctive callees, member-offset writes, or constants (the FNV0x100000001B3; literal factory-call args like6/4). A near-match in line count / arg count is expected to drift with inlining - judge by behaviour, never by size. - Don't trust the target DB's auto-names or hexrays' inferred prototype.
The fresh DB mislabelled a 123 KB function as
ItemInstance::fromTagwhile the correct small one was an unnamedsub_; and the real 2-arginitializePackSource(this, PackSourceFactory&)decompiled as a 4-arg(__int64*, const char*, __int64, __int64). The body is ground truth; the label and the prototype are guesses. - Cheap pre-filter for a 60+ entry table: for each entry, confirm the offset
is a function start and that its referenced string set is a superset of
the old named function's strings (strings are version-stable). That clears
the string-bearing majority; decompile-and-compare only the string-less
residue. (Callee-name overlap does not work - the target DB's callees
are almost all unnamed
sub_.)
- Same RVA != same function across versions. Do not identify the new
function by reading what name sits at that RVA in the old DB - code
relocates every release, so the old DB's
- Beware a stale PDB overriding your fix.
--pdbresolves by name first, so a PDB older than the exe returns the old RVA for any moved symbol, ignoring your re-cut pattern. When the PDB version can't be trusted, do not blanket-regenerate (it can clobber currently-correct offsets with stale ones). Instead fix the one verified entry insrc/bedrock/symbols/<platform>.hdirectly (hand-patch the offset) and update thepatternfor the next clean regen. Cross-check the other platform - the same function on Linux (_ZN...) often resolved fine (different codegen), confirming a Windows-only change.
Detecting vtable changes (Linux RTTI)
Confirm directly against the binary whether a virtual was added / removed /
reordered, and at exactly which slot - name-free. Every polymorphic class has
_ZTV<len><Class> + _ZTI<len><Class> even in a fresh, PDB-less DB; the slot
targets are sub_ in both DBs (even the named reference only has RTTI symbols,
not the virtuals), so you diff layout without any virtual-function names.
- Walk the vtable from its address point. Resolve
_ZTV<len><Class>, skip each(offset_to_top, _ZTI<len><Class>)header pair, then collect qwords while the target sits in an executable segment (ida_segment.getseg(q).perm & 1) or is__cxa_pure_virtual; stop at the first non-pointer / next typeinfo. Do not gate onida_funcs.get_func- the fresh DB has not defined most vtable targets as functions yet, so it stops the walk early; exec-segment membership works regardless of analysis. - Length per class brackets the change. A primary vtable lays out
[base virtuals][derived adds]in order, so each class's own_ZTVlength localises a net add/remove to one class. For a hierarchy, the most-derived concrete class (e.g.ServerPlayercoversActor -> Mob -> Player -> ServerPlayer) gives the whole chain in one read. Equal length on every level = no net change (still verify order). 2b. Never end a vtable run at "the next qword is not code" - on Linux either. The Windows warning about packed vftables applies verbatim to.data.rel.ro: when the object following a vtable is an RTTI-less function-pointer table (an entt-meta / cereal type-erased manager, a dtor/copy/move/compare/hash block), the run-length walk sails straight through it and reports a class as dozens of slots longer than it is. The terminator is the next vtable's own header - a zerooffset_to_topslot followed by a typeinfo pointer - not the first non-code qword. Cross-check every suspicious length against the DERIVED classes: a derived vtable can never be shorter than its primary base's, so if every deriver measures 29 in both versions, the base's "55" is the artifact, not a removal. 1.26.51 produced two such false positives this way,BaseCircuitComponentreading 55 -> 29 andRemoteConnector-56 reading 22 -> 20; both classes were completely unchanged. 2b-net. A length sweep cannot see a NET-ZERO change, and BDS makes them. 1.26.51 removed one virtual fromActor(canFreeze) and added one toPlayer, soActorread 138 -> 137 andMob176 -> 175, whilePlayerandServerPlayerread 246 and 248 in both versions and never entered the changed list. Every Player-level virtual after the insertion point was one slot off;sendNetworkPacketlanded onsendComplexInventoryTransactionand faulted deep inside BDS the moment a player joined.- Whenever a base's length changes, re-check every deriver by ALIGNMENT, not by length. A deriver whose length is unchanged has, by definition, gained exactly as many virtuals as its base lost.
- Better: run the per-slot
difflib.SequenceMatcheralignment (step 4) over every class Endstone declares and report onlyinsert/deleteopcodes. It is the same walk the length sweep already does plus a 9-instruction signature per slot, and it is the only sweep that catches this.
2c. The class-level length sweep has two blind spots - close both. The
usual sweep (every _ZTS name -> vtable length in each binary, intersected
with the class names Endstone declares) silently skips:
- Abstract bases whose vtable is never emitted. Their
__class_type_infois only referenced from derived__si_class_type_inforecords, so the offset-0 address-point search finds nothing and the class never enters the table.ItemDescriptor::BaseDescriptorgained a virtual in 1.26.51 and did not appear in the sweep at all; only its five derivers did, each 18 -> 19. So read the sweep as "some class in this hierarchy changed" and walk up to the base Endstone actually declares. - Classes Endstone declares in a
.cpp, not a header. Scan**/*.cppalongside**/*.hwhen building the "what Endstone declares" set -InternalItemDescriptorlives initem_descriptor.cppand was invisible to a header-only scan.
- Structural fingerprint confirms no same-count shuffle, name-free: tag each
slot
P=__cxa_pure_virtual,T= this-adjusting thunk (48 83 ef/48 81 ef=sub rdi),R= repeats previous target (shared-stub runs),.= normal. Identical tag strings across DBs => no reshuffle. - Localise the exact slot by signature alignment. Per slot, build a
recompilation-robust signature - decode the first ~6 instructions
(
ida_ua.decode_insn) keeping mnemonic + operand register classes but dropping immediates and displacements. Bridge the two IDA processes via a temp file: dump the old DB's per-slot signatures to JSON, switch DBs, recompute,difflib.SequenceMatcherthe two lists.insert/deleteopcodes are the real structural change;replaceopcodes are functions whose body changed at the same slot - ignore them. - Confirm by decompile, never by the heuristic alone
([[feedback_decompile_to_confirm]]). Decompile the boundary in both DBs: the
shifted neighbour (
NEW[slot+1]) must matchOLD[slot], and the insertedNEW[slot]is often referenced by its shifted neighbour (e.g. a new per-position helper the shifted loop calls viavtbl+offset) - the tightest possible confirmation. - Map the slot to the Endstone declaration, then validate locally. Count
the header's virtuals (dtor = 2 slots; each overload = 1; skip commented-out;
honour
#ifdef __linux__) plus any base's slots. Anchor the count on a virtual whose address you know in both versions (a hooked one from the toml) - its slot must equal your predicted index. Beware: an Endstone header may omit Linux-only virtuals (the#blameMojangones), so the cumulative count can be short of the real vtable - do not trust it globally. Decompile the few slots around the change and match them to neighbouring declarations (a const/ non-const overload pair returning the samethis+Nsubobject is an unmistakable anchor). If the local sequence lines up, the insert point is pinned regardless of any global gap. - Add the placeholder. A single non-dtor
virtual void <name>() = 0;occupies exactly one slot. Name it a clearly-marked placeholder (don't invent a Mojang name) and comment the observed signature/behaviour. If you cannot confirm the change is on both platforms, match the existing#ifdef __linux__pattern rather than risk shifting the Windows vtable.
For one class you do not need IDA at all: lief + capstone on the shipped
binaries resolve _ZTS<len><Class> -> typeinfo -> vtable -> per-slot disassembly
in seconds, on both the previous and the new ELF. (lief's Binary.relocations
comes back empty on the stripped BDS ELF - parse .rela.dyn yourself as 24-byte
(offset, info, addend) records and keep info & 0xffffffff == 8.)
Locating a vtable on Windows (no RTTI)
/GR- leaves no type descriptors, but a small interface is still findable
name-free - and this is the only way to confirm a Linux-derived vtable verdict on
the platform Endstone actually hooks.
- Scan
.rdatafor a run of consecutive pointers tolea rax, [rcx+d]; retstubs. Trivial base-subobject getters are ICF-folded to one stub per displacement binary-wide (~100 in a 160 MB.text), so a class with N of them is a run of N adjacent stubs with distinct increasing displacements, and the displacements read off the member layout directly. These stubs are 16-byte aligned andcc-padded but have no.pdatarecord (leaf, no unwind) - a.pdatafunction-start filter silently drops every one of them and the scan returns nothing. - Identify the class from the dtor slot, not a name. The slot before the run
is the scalar deleting dtor; its teardown pins both the class and its
sizeof(a virtual-deletingunique_ptrat+N= the last member). Comparing that body against the last release that shipped a PDB is the identification. - Never read the table's END from "the next qword is not code". MSVC packs
vftables back to back in
.rdata, so the next table's slot 0 is a code pointer - the same class reads as 4 slots in one release and 8 in the next purely by what the linker put after it. The terminator is: the next qword's own address is the target of alea+mov [reg], raxvfptr store. - A displacement proves the slot order, never the semantics. Which member a
getter returns comes from the last PDB-bearing release (
??_7<Class>@@6B@plus the named getters); carry that mapping forward version by version and diff displacements. Equal displacements at equal slots across the chain is what makes an "unchanged" verdict conclusive rather than merely shape-compatible. - To check ONE virtual's slot index in a 400-slot table, don't walk the
table - find the function (byte fingerprint of its body, member displacement
wildcarded), locate the
.rdataqword holding it, and walk backwards while the qwords are.textpointers; the distance is the index. Do it in the PDB-bearing release first to learn the constant offset between the binary index and Endstone's declaration count (a base contributing only a virtual dtor is+1), then apply the same offset to the new release. Cross-anchor on a const/non-const overload pair - ICF folds them to one address, so they show up as two adjacent slots sharing a target, which is unmistakable.
Tracing a Bedrock::PubSub notification path
When an Endstone event fed by a Connector/Publisher stops firing, clear or
convict the BDS side before touching src/bedrock/. Four checks settle it.
- Resolve a slot-numbered lead to a NAME first. Itanium spends 2 slots on
the dtor, MSVC 1, so the same virtual is Itanium slot N and MSVC slot N-1 -
an off-by-two between platforms. Name the slots from the last PDB-bearing
release (the proxy/manager virtuals are usually public even when the vftable
is not) and confirm by
.textaddress order, which follows declaration order. Acting on "slot 5 was restructured" without this reads avoidhelper as the notification gate. - A
dispatch<...>instantiation is per-signature and normally has exactly ONE caller. Xref it in both binaries: equal caller sets prove there is a single publish site and it did not move. This is far stronger than diffing the publisher, and it is twoE8rel32 scans. - Read the notifier itself, not the publisher.
Level::onChunkLoaded-style notifiers are thin: a chain of proxy vcalls (a read-only early-out, a fire-once latch returningbool, some side effects, an argument getter) then the dispatch. Diff it instruction-by-instruction across versions - a stable one stays byte-identical apart from relocated displacements. - Ordering against a state field needs the notifier's caller, not the
notifier. Find it by the literal argument pair at the state-transition
call (
tryChangeState(expected, desired)); that pair is version-stable and usually unique. Beware: BDS discards the CAS result and often publishes outside the lock, so an Endstone-sidestate >= Xgate is unsound by construction even when the ordering is unchanged. Prefer the guarantees BDS already provides (the fire-once latch) over re-deriving them from a field.
Detecting data-member layout changes (ctor/dtor RE)
A struct's member layout - a member inserted, removed, resized, or moved - is recoverable directly from its constructor and destructor, no headers needed. Usually crash-driven.
- The crash points at the member. Map the crashing read (an accessor /
_get/_setControlBlock/unique_ptrderef) back to the Endstone member, then diff that struct's ctor/dtor: new (stripped target) DB vs the previous named-reference DB. - Two-DB diff. The previous DB has PDB symbols (named ctor
??0Class@@, dtor??1Class@@); the new one usually does not. Extract each member's offset from both and align top-down. The first offset that differs localises the change; the delta is the size inserted/removed before it. Confirm a later anchor member shifted by the same delta (a non-uniform delta means more than one change - keep going). - Extract offsets with a
this-relative store tracker. Decompiled ctors are noisy. Run a small register-tracker over the ctor: seedthis(rcx)=0, propagatethis-derived values throughmov/leareg copies and stack spills, and log everymov [reg+disp], ...whose reg isthis-relative plus everycallwhose rcx isthis-relative (member sub-object ctors). Sorted offsets = layout in construction (= declaration) order. Same engine on the dtor gives teardown order. - Identify a member's TYPE by its ctor/dtor fingerprint (MSVC sizes):
std::string(32): ctor writes capacity=15at+24; SSO test is the0x80000000000000bit on the capacity word.std::vector(24): ctor zeroes 3 pointers; dtorif (begin) operator delete(begin, end - begin)- one sized free off the stored last/end pointers.std::unordered_map/unordered_set(always 64, any K/V): ctor sets load factor1.0f(0x3f800000), mask=7, bucket count=8, a 32-byte sentinel list node; dtor frees the bucket vector then walks the list. The per-nodeoperator delete(node, N)reveals the value type via node size.shared_ptr/weak_ptr(16) andBedrock::NonOwnerPointer<T>(24 = shared_ptr 16 +T*8): dtor is an atomic refcount release -if (rep) { atomic_dec(rep->uses); vcall rep->__on_zero(vtbl); ... }. This tells a 24-byteNonOwnerPointerfrom a 24-bytevector: refcount-release-with-virtual-call vsoperator delete(begin, end-begin).unique_ptr<T>(8): dtorif (p) { ~T...; operator delete(p, sizeof(T)) }. PolymorphicT-> virtual deleting dtor(**p)(p, 1); concreteT-> fixed-sizeoperator delete(p, N). Distinguishes which of two adjacent unique_ptrs moved, and tellsunique_ptrfrom a raw pointer (not destroyed).- Other fixed sizes:
std::function64 (dtor calls a manager via a stored vtable),BaseGameVersion32,Core::Cache72,AABB24,HashedString48,mce::Color16.
- No symbols and no locatable ctor/dtor -> Linux RTTI.
_ZTV<len><Class>exists in a stripped Linux DB; the dtor is atvtable+16(Itanium D1 complete) /+24(D0 deleting). Decompile it and read the teardown the same way. Caveat: libc++ layouts are not the MSVC offsets, but member order and member kind are the same, andvector(24)/shared_ptr(16)/NonOwnerPointer(24) match Windows sizes - enough to confirm what kind of member changed and where in the order. Diff the new Linux dtor vs the old to see the extra/missing teardown (the new member appears as an extra teardown adjacent to its neighbour). - Caveats.
- A member the dtor destroys is owned (vector/string/smart-ptr/unique_ptr); a reference or raw pointer member never appears in the dtor, so absence there is not absence in layout - cross-check the ctor store list.
- Check what Endstone actually accesses before padding the unused middle
(grep the
*Ref/wrapper that exposes the type) - if it reads a tail member, the whole tail must be byte-accurate, not just padded to size. - Confirm by decompile, never by size/heuristic alone ([[feedback_decompile_to_confirm]]); the tightest confirmation is a shifted neighbour whose new offset equals the old member's offset.
Start from the crash, not from a sweep
A layout bug almost always surfaces as an access violation inside a trivial
accessor that just returns a member - getX() { return x_; } - or inside
NonOwnerPointer::_setControlBlock / a shared_ptr copy, because those touch a
control block and fault on garbage. When that happens:
- Read the displacement out of BDS's own accessor in both versions. That one number is the whole answer and takes minutes; a full ctor/dtor walk takes hours. Do it first and only escalate if the accessor cannot be located.
- Fix, rebuild, re-run. Each fix moves the crash one step further in, and the next trace names the next class for free. Iterating the crash is dramatically faster than trying to statically clear every class up front.
- Rule out your own recent edits before blaming BDS. If a hook's declared
return type or parameters changed, a corrupted
thisproduces the identical symptom. Discriminate by where it survives: if the hook already called the real function throughENDSTONE_HOOK_CALL_ORIGINALwith thatthisand got back,thisis fine and it is a member offset. - Beware the accessor that appears to work: a
shared_ptr's pointer is its first 8 bytes, so a getter returning.get()keeps working while every member after it is 8 bytes out. Silent, and it hides the real breakage.
When the crash leaves NO report: 0xC0000409 is a vtable slot shift
Exit code 3221226505 / 0xC0000409 is __fastfail, and subcode 0xA is
FAST_FAIL_GUARD_ICALL_CHECK_FAILURE - Control Flow Guard refusing an indirect
call whose target is not a registered function entry. That is the signature of a
virtual call landing on the wrong slot: the loaded qword is a valid-looking
address (a data table, a vtable's tail, the middle of a function) but not a CFG
call target. An access violation would at least give you a trace; a fast-fail
bypasses SEH, so crashpad writes nothing and crash_reports/ stays empty.
Do not reach for a debugger - bisect with fprintf(stderr, ...) + fflush.
Two builds is usually enough: one marker per top-level stage to find the
function, then one marker per statement inside it. 1.26.51's went
recipes -> shapeless[0] -> ing serialize and stopped, naming
ItemDescriptor::serialize(Json::Value&)'s impl_->serialize(json) as the bad
dispatch in two rebuilds. Then take the concrete class the call dispatches on
(here InternalItemDescriptor) straight to the Linux RTTI vtable diff.
Related trap: Endstone's devtools thread dumps every block, item, recipe and biome at startup, so it exercises far more of the ABI than a bare boot does. A bump that "boots fine" with devtools off is not a bump that works - leave devtools on and treat its dump as the acceptance test.
The change BDS actually makes most often
A member changing KIND at an unchanged offset, growing 8 -> 16 and shifting
everything after it - overwhelmingly std::unique_ptr -> std::shared_ptr, and
it tends to arrive in clusters across ownership-holding classes in one release.
When you find one, go looking for its siblings in related classes before the
next crash finds them for you.
Judge it by kind, not size: a shared_ptr teardown is an atomic refcount
decrement plus a virtual __on_zero call; a unique_ptr reset is an inline
delete. Same 8-byte delta, completely different fingerprint. Other shapes seen:
a unique_ptr<T> replaced by an inline std::optional<T> (the destructor stops
running a deleter and starts testing an engaged flag over the value's own body),
and a member relocated within the struct with sizeof unchanged - which no size
check can ever detect.
A class can keep its name and API but move its BODY behind a pointer
sizeof sweeps, vtable sweeps and RTTI all say "unchanged" when BDS turns a
class into a handle - the old body becomes a separate allocation and the class
shrinks to a single pointer. ResourcePack did this in 1.26.51: it stayed
non-polymorphic, stayed named the same, and every accessor Endstone calls kept
its signature, but sizeof went from a few hundred bytes to 8.
The tell is one extra mov rax, [rax] in a BDS function that reads through
the object, so diff the chain of indirections, not the endpoints:
1.26.45 mov r8, [rbx+0x10] ; ResourcePack*
mov rax, [r8+8] ; -> Pack*
mov rax, [rax] ; -> PackManifest*
1.26.51 mov r8, [rbx+0x10] ; ResourcePack*
mov rax, [r8] ; -> body <-- new hop
mov rax, [rax+8] ; -> Pack*
mov rax, [rax] ; -> PackManifest*Pick any BDS function that walks the class (a getter, a filter loop, a
comparison) and read the two side by side. Confirm at runtime by dumping the
first qwords of a live object: the handle's own tail is unrelated heap (an NT
heap block header at +8 says the allocation really is 8 bytes), while the
pointer at +0 leads to something with the old layout - zeroed leading bools,
the same shared_ptr at +8, the same null unique_ptr at +24.
Port it by nesting the old member list in a Body/Impl struct and leaving one
pointer in the class; nothing else in Endstone has to change, because the
accessors keep their signatures.
Proof techniques that settle it quickly
- A single instruction changing WIDTH at an unchanged offset proves an
append. A ctor's
mov qword [this+N], 0becomingmovups xmmwordmeans a new 8-byte member now sits atN+8and is zero-initialised with its neighbour. Identical on both platforms, and hard to misread. - Uniform-delta check over the whole object. If every store from the first divergence to the end moved by exactly the same delta, there is exactly ONE change and nothing before it moved. A mixed band (some +8, some 0, some +16) means multiple changes - keep going.
- Index-align the two dtors' displacement LISTS, don't set-difference them.
Collect the distinct
this-relative displacements each version's D1 touches, sort both, and pair them up by index. When the two lists are the same length the pairing is exact and every shift boundary falls out in one read - a run ofd -> d, then a run ofd -> d-8, thend -> d-16says there are two independent 8-byte shrinks and tells you the offset each one starts at. A set difference of the same two lists just yields two unaligned piles that look like seven unrelated changes.ResourcePackManager @ 1.26.44: unchanged through 144, -8 from 160, -16 from 360, which located both shrinks without decompiling anything. Cross-check the count first - if the lists differ in length, a member was added or removed and index pairing is invalid. - Base-class removal is visible in the typeinfo kind. Itanium
__vmi_class_type_info(multiple bases, with the secondary vtable groups) ->__si_class_type_info(single base) is a removed base, and the removed base's own_ZTSname disappears from the whole binary. Every member then shifts by that base's size. - RTTI
offset_to_topdoubles as a size oracle for a base subobject. A secondary base'soffset_to_topmoving -32 -> -40 says the primary subobject grew 8 bytes, without decompiling anything. make_shared's allocation size is a whole-object oracle - subtract the right control block. Itsoperator newimmediate iscontrol block + sizeof(T): 16 on MSVC (_Ref_count_obj2= vptr + twouint32; theadd reg, 0x10that derives the object confirms it) and 24 on libc++ (its counters arelong, notint). Subtract the wrong one and every size is 8 bytes out. This is the only size oracle Windows has,/GR-leaving no typeinfo. For a packet the route needs no symbols beyond one the table already holds:MinecraftPackets::createPacketis a jump table indexed directly byMinecraftPacketIds, sotable[id]->make_packet<T>->operator newis two hops. Cross-check on Linux, where the D0 dtor's sizedoperator delete(this, N)givessizeofindependently.- A factory's ordered
operator newimmediates fingerprint it across versions - the name-free way to carry a PDB-namedsizeofforward. The function that builds a big object allocates dozens of sub-objects, and that ordered list of immediates is effectively unique and survives a release nearly unchanged. Match the list in the new binary to re-identify the same factory, and the one entry that moved is the newsizeof.ServerLevel(33 allocations, only slot 2 changing0x998->0x9a0) andServerScriptManager(0x4e8->0x500) were both settled this way on Windows with no RTTI and no PDB. Read the immediate from theoperator newargument, or from themov qword [rsp+0x28], Nthe allocation-failure assert spills - the latter is greppable as a byte pattern. - When no
operator newsite exists, MSVC's scalar deleting destructor has the size. A class that is only ever stack-constructed (most packets -StartGamePackethas nooperator newimmediate anywhere in the image) still getsoperator delete(this, sizeof(T))emitted in vftable slot 0, so onemov edx, Nsettles it. Reach slot 0 name-free: a string literal the class owns -> the stub referencing it -> the.rdataqword holding that stub -> walk back while the qwords are.text. The walk over-runs into the previous vftable (MSVC packs them back to back), so take slot 0 to be the scalar-deleting-dtor body (mov [rcx], vftable...test edx,edx->operator delete), not the start of the run. - Sweep every Linux
sizeofat once off the Itanium D0._ZTS<len><Class>-> typeinfo -> address point -> the deleting dtor'smov esi, N; jmp <sized operator delete>issizeof, name-anchored, and runs over a list of classes in seconds withliefalone. Two filters make it trustworthy: the address point'soffset_to_topslot must be 0 and not relocated (a derived class's__si_class_type_infobase-pointer field otherwise reads as a fake address point and hands back the derived class's size - that is howConnectorreads 200 instead of 16), and the D0 must be a <=26-instruction body that calls the slot before it. The dtor pair is not always slots 0/1 -Actor/Mob/ServerPlayercarry it at slots 8/9 - so scan the first ~20 slots. - BDS's Windows build is clang-cl, so
__PRETTY_FUNCTION__literals bridge the platforms.T *Bedrock::NonOwnerPointer<X>::_get() const [T = X]and the assert expressions are byte-identical in both binaries; only the source-path literal differs (/mnt/vss/_work/1/s/...vsD:\a\_work\1\s\...). Take the string out of the Linux D1 you just found by RTTI, locate it in the Windows.rdata, scan.textfor thelea ripthat references it, and the enclosing function is the Windows destructor - its deleting-dtor caller carries the Windowssizeof. This is what settles a class with no vftable anchor; it fails only for destructors that reference no distinctive literal. - A size delta is a NET. 1.26.40's
StartGamePacketreads -8 on Linux and -24 on Windows: a cereal migration appending 8 at the tail on top of a member that shrank 16/32. Decompose before reading anything into the sign or the magnitude - one component can be string-free (equal deltas) while another is not. - The Windows-minus-Linux delta counts the strings. A change adding N
std::strings showsdelta_win - delta_linux == 8 * N(32 vs 24); equal deltas mean no string moved. Decompose a size jump with it before trusting the decomposition - and never assume the sweep's Linux delta is the Windows one. std::optional<T>is self-measuring. The engaged-flag offset read by a writer or dtor issizeof(T)past the value, so onecmp byte [this+K], 1pins both the member's start and the payload's size in one instruction.
Traps that cost real time here
- The destructor only sees OWNED members. Deriving layout or
sizeoffrom the D1/D0 dtor is blind to raw pointers, references and PODs, so an inserted non-owning member is invisible and the method will happily report "growth is all at the tail" when a mid-struct insertion actually moved everything. Treat a dtor-only verdict as a lower bound and cross-check the ctor store list. - A
this-tracker must follow stack spills. Any function big enough to matter spillsthisimmediately; a register-only tracker finds one or two offsets in a 30 KB function and looks like it worked. - Per-platform deltas are NOT transferable.
std::stringis 32 on MSVC and 24 on libc++,unordered_map64 vs 40, and mutexes differ too. A changed verdict transfers across platforms - the same source change - but the byte delta and every resulting offset must be derived per platform. - The typeinfo name string is not guaranteed NUL-preceded. Linker string packing can place another string's tail immediately before it, so a scanner anchored on a leading NUL silently misses classes - and looks like the class simply is not there. Search on the trailing NUL and validate by finding the typeinfo object that points at the candidate.
- PIE
.data.rel.rois mostly zero on disk. The real pointers live in theR_X86_64_RELATIVEaddends; reconstruct the section as raw bytes overwritten by addends.offset_to_topis not relocated, which is exactly what delimits sub-vtables - so using addends alone silently loses the sub-table boundaries. - Windows sret is RDX, not RCX. For a member function returning a large
aggregate the convention is
rcx = this,rdx = return buffer,r8onward = arguments.
Guarding against the next one
BEDROCK_STATIC_ASSERT_SIZE cannot protect a class Endstone truncates with
// ... - its sizeof is the declared prefix, not the real object - and those
are exactly the classes that fail this way, silently, at runtime. The guard that
would catch it is asserting the offset of the last member Endstone reads
(offsetof), but on a non-standard-layout class (multiple bases, virtuals) that
is only conditionally-supported and warns. Decide it once as a convention rather
than per class. For any class that is not truncated, add the size assert - it
turns this whole failure mode into a compile error.
A tail-only size delta still bites wherever sizeof is load-bearing - check all
THREE ways before calling one benign. Growth past the last member Endstone reads
looks harmless, and per the ServerLevel note below the right move is then to stop
rather than manufacture a placeholder - but only after ruling out each of:
- Stride. A class stored BY VALUE in a container has
sizeofas its element stride, so a shortfall silently misreads every element after the first.AttributeInstance @ 1.26.51grew 8 bytes purely at the tail - destructor displacements byte-identical to 1.26.45, both value arrays still at +104 and +116 - and still had to be fixed, becauseBaseAttributeMapkeeps its instances in abrstd::flat_map'sstd::vector<AttributeInstance>. - Variant storage. A
std::variant's discriminant sits immediately aftermax(sizeof(alt)), so growth in the largest alternative - or in anything an alternative holds by value - moves the index Endstone reads and lands you inbad_variant_access. The alternative need not be the class that changed: the event structs holdItemInstance/ItemStackby value, so those types' sizes feed the variant even though nothing names them as alternatives. - Base subobject. Anything deriving the class shifts by the same delta.
Only a class that fails all three - held solely through a pointer, never a variant
alternative nor a by-value member of one - is genuinely free. Item and its subclasses
(SharedPtr<Item> throughout the registry) and ActorDamageByBlockSource (events hold
damage sources by reference or smart pointer, never by value) are the worked examples.
Grep for the owner before deciding; do not infer it from how the class "looks".
Sweeping check (2) is cheap and worth doing every bump. Scan .text for the
libc++ visit idiom - mov e?x, dword ptr [reg + N] immediately followed by
mov e?x, 0xffffffff - and collect the set of N. Diff that set across the two
binaries: an offset that appears or disappears is a variant whose storage changed.
Cross-check the per-handler view too, by walking each Script*GameplayHandler
vtable and reading the offset each slot loads, then matching it against the
__index offset clang reports for the variant Endstone declares
(-fdump-record-layouts, taking the LAST __index_t __index in the record - the
earlier ones belong to variants nested inside alternatives). 1.26.51 moved exactly
one, MutableLevelGameplayEvent from +24 to +40, and left every offset <= 400
otherwise untouched - which also proves transitively that ItemInstance and
ItemStack did not change size, since they feed those alternatives by value.
Sweep the RAW static_assert(sizeof(X) == N) form too. Not every guard uses
BEDROCK_STATIC_ASSERT_SIZE; a handful of headers carry a hand-written, and therefore
platform-blind, static_assert instead. A sweep that greps only the macro reports the
class as unguarded, and you then add a second assert next to the stale one and get a
build failure quoting a number you never typed. Grep both spellings, and replace the
raw one with the macro when you touch it.
Two things that make the guard weaker than it looks:
- A size assert never checks BDS.
static_assert(sizeof(X) == N)compares Endstone's declaration against a hard-coded literal, so onceNis stale it passes forever and reads as verified. Re-derive every asserted number from the new binaries each bump - a green build says nothing (1.26.40:ResourcePacksInfoPacketasserted 128 against a 136-byte object,ClientboundMapItemDataPacket200 against 208; both packets are read and mutated by hooks). - Sweep the asserts mechanically, but trust the DIFFERENTIAL, not the absolute.
Parse every
BEDROCK_STATIC_ASSERT_SIZEfor its class name, mangle it to_ZTS<len><Class>(nested ->N..E), and run the Itanium-D0sizeoforacle against both the old and the new binary. Comparing new-vs-old is reliable because the same picker runs on both; comparing the oracle's absolute answer against the asserted literal is not - a heuristic that takes the first early slot tail-calling sizedoperator deletemis-fires on abstract bases and on classes whose dtor pair is not at slots 0/1, and reports nonsense likePacket= 8 orPlayerAuthInputPacket= 72. Validate the method per class by requiring it to reproduce the old asserted number first; where it does, a changed new number is real. 1.26.44's only genuine hit wasResourcePackManager416 -> 400 (Linux), and the method reproduced 416 exactly on 1.26.40, which is what made 400 trustworthy. - Non-polymorphic classes are invisible to this sweep (no vtable, no D0) -
in practice ~half the asserted list, including most
*Payloadstructs. Report them as unresolved rather than as "unchanged"; they need the cereal-manager or factory oracles instead. - No
// ...marker does not mean the class is complete.ServerInstancecarries no marker yet declares 936 of 1080 bytes. So "add asserts to every unmarked class" is not a mechanical sweep: derive the real size first, and where the declaration is short either finish it or add the marker - never both a silent prefix and an assert. - Read the Endstone header before measuring anything. A class that declares
no data members at all has no offset that can be wrong, so any
sizeofchange is a non-event however large, and an assert on it would be false rather than protective.ServerLevelisclass ServerLevel : public Level {};and bothLevelandILevelare virtuals-only, so 1.26.40's +8 could not reach Endstone;ServerScriptManagerdeclares 0 of its 1280 bytes and is only ever held byunique_ptr. Confirm the growth really is outside the declared prefix rather than assuming it - but then stop, and do not manufacture an edit.
Detecting cereal-packet layout changes (the cereal manager)
Once a packet is migrated to cereal serialization, its write(BinaryStream&)
and toString virtuals stop touching members - write is a thin wrapper that
builds a default ReflectionCtx and tail-calls the ctx serializer, the real work
runs through a reflection visitor, and toString is a stub (pre-migration) or
reflection (post). Do not decompile them for layout - they are not oracles.
The oracle is the packet's cereal manager (its type-erased op function), plus
the SerializationMode accessors.
- Find the manager. Each cerealized packet has one
switch-on-intfunction (aswitch 5 casesjumptable) wired into the packet at construction (*(obj+16) = <manager>in the ctor /make_sharedbuilder) and referencing the packet vtableoff_<vtable>. Its cases:- case 1/2 = copy/move-construct -> a faithful memberwise copy (exact offsets and widths).
- case 4 = clone ->
operator new(SIZE)then the same copy (authoritative object size). - case 0 = type descriptor / entt type-name; case 3 = compare. Ignore both.
- Read the ASSEMBLY of case 1/2, never the decompile. Hex-Rays mixes pointer
units (
v4[7]vs*(v4+38)) and hides widths; the asm is exact:movss [dst+off]= 4-byte float;mov eax,[src+off]; mov [dst+off],eax= 4-byte;mov rax,...= 8-byte;movups xmm...= 16-byte block / inlined sub-object.movzx eax, byte [src+off]; mov [dst+off], al= a 1-byte member. Adjacent 1-byte stores at consecutive offsets = a member that narrowed (e.g. a 4-byte enum -> 1 byte).lea [src+off]; lea [dst+off]; call <helper>= a non-trivial member (string / vector /RedactableString) atoff.- A member present in the old layout but with no store in the new copy = removed.
operator new(N)in case 4 = the size. Compare toBEDROCK_STATIC_ASSERT_SIZE. Beware: 8-byte alignment rounding can keepsizeofunchanged while members narrowed / dropped / were added - never trust size alone, read the copy.get/setSerializationModebetray a cereal-only migration. Pre-migration both are stubs (Windows: COMDAT-folded ontodisallowBatching's address; Linux: literalreturn 0/ nullsub). Post-migration the packet overrides them: the vtable slots becomemov eax,[this+OFF]/mov [this+OFF],reg, andOFFis a new 4-byteSerializationModemember (initCerealOnly=5). Find the slot via the PDB-anchored_readslot (Windows) / RTTI (Linux) and confirmOFFequals the copy's last store. With no PDB, skip the vtable entirely:getId/getName/get+setSerializationModeare emitted as one contiguous aligned stub cluster in the packet's own TU on both platforms, so the single.textrip-leato the"<Name>Packet"literal lands ongetNameand the next two stubs give the packet id and theSerializationModeoffset for free. On Windows those stubs are leaves with no.pdatarecord, so a function-range filter silently drops them - and the cluster itself is only dependable on Linux: ICF foldsmov eax,[rcx+OFF]; retacross every packet sharingOFF, so on Windows the mode stubs usually live elsewhere and onlygetId/getNamesit beside the literal. Take the mode from vftable slots 12/13 there (MSVC spends one slot on the dtor, Itanium two). WhereOFFis unique to the packet nothing folds and the Windows cluster does carry all four (+0x178/+0x8a8forInventoryTransactionPacket/StartGamePacket@ 1.26.40);getIdsits beforegetNameas often as after.- A packet still in a side-by-side mode keeps its hand-written
write- and that is the best member oracle there is. Only aCerealOnlypacket'swriteis the thin wrapper point 1 warns about; aSideBySide_*/SemanticSideBySide_*one reads every member at its real offset with the field's cereal name string in the adjacent argument register. It names offsets and covers the PODs and raw pointers a destructor never touches, so it settles the mid-struct-insertion question a dtor walk cannot. The<Name>Payloadtype name and its member display names also appear verbatim in the entt/cereal registration function - oneleaper name with the length inecx. That order is the wire order, which is not always declaration order (ResourcePackStackPacketPayload@ 1.26.40 registers itsboolfirst while the constructor stores the vector first). Take names from the registration, offsets from the constructor. 5b.protocol-docsships one branch per release - diff it FIRST, before opening a binary.r26_u4is 1.26.45,r26_u5is 1.26.51, andgit diff origin/r26_u<prev> origin/r26_u<new> -- packets types enumsnames every packet and type whose wire changed in seconds. Intersect that with the headers Endstone declares and you have the actionable set without a single decompile. It also separates real changes from serializer churn: a release that drops the unnamed{"type": "bool", "value": true}constants everywhere (1.26.51 did) is a cereal change with no struct impact, so a packet whose whole diff is those lines -PlayerAuthInputPacket,InventorySource,InventoryTransaction- needs nothing. 5c. The documented wire type gives the C++ enum's WIDTH. cereal writes a 1-byte enum asuint8and a 4-byte one asuvarint32, so protocol-docs'"type"is a direct read of the underlying type. Validate the rule on two enums you already declare, then trust it: 1.26.51's newItemUseInventoryTransactionmember documented"type": "uint8", "enum": "HandSlot"provedHandSlothad narrowed fromint, which no size check could see (both widths give the same layout). The binary'scereal::internal::TypeSchema<HandSlot>string confirms the member's type independently - grep for it rather than inferring from the field name. 5d.make_shared's zero-init stops at the payload's DSIZE, which settles declaration order when the size cannot. The factory zeroes[object, dsize)using aligned 16-byte stores plus one trailing unaligned store, so the end of that last store is the offset just past the final member. When it lands on an odd offset the last-declared member is one byte wide - that alone ruled out wire order forPlaySoundPacket @ 1.26.51, whose two new fields are documented as aboolbefore the sound handle and an optional after it, but whose zero-init ends at payload+73. Both orders gavesizeof80 on both platforms, so nothing else discriminated them. - Cross-check the wire with protocol-docs. The cereal field set == the
serialized fields;
EndstoneMC/protocol-docs(<branch>/packets/<Name>.json) lists them in order, mapping the copy's offsets to names and flagging added/removed wire fields. A field can leave the wire and the struct together (dropped bools), or appear (a secondFilteredNameonce aRedactableStringserialises both halves). The siblingbedrock-protocolDSL often already models the target protocol; a wire model that matches the binary field for field, optional for optional, is a second independent derivation. - Date the migration with a raw
grep, before opening any database. A packet that migrated carries a<Name>Payloadtype-name string and loses the field-label literals its hand-writtenwritepassed to the stream ("DecorationUpdate Bit Field"). Grepping both strings across the shipped binaries of the old and new release pins the migration to the exact version in seconds, and tells you up front which oracle applies (point 5 vs point 1). - The payload's own constructors are the oracle when there is no
hand-written
write. Reach them from theoperator new(sizeof)site (which also hands yousizeof): the ctor stores every member, PODs included, and names each one by the argument it comes from - a full member map with semantics, not just offsets. Different ctors light up different halves (an update-path ctor sets the scalars, a savedData-path ctor fills the containers), so read more than one. - A tail append does NOT mean nothing before it moved. A migration wraps
the members in a payload sub-object and is free to reorder them into wire
order at the same time; the
SerializationModeat the tail then accounts for the whole size delta and the arithmetic looks like a pure append while every member has actually shifted.ClientboundMapItemDataPacket @ 1.26.40is exactly this: 200 -> 208, all 8 bytes explained by the tail member, yet every one of its 13 members moved. - Watch for a scalar hoisted out of a container. The same migration split
mMapIds.front()into a new standaloneActorUniqueIDat the head of the payload plus a vector that is now empty on every non-creation path. An Endstone accessor written asvec.front()stops being merely wrong and becomes UB. The empty container is not an error to guard - the value moved. writeWithSerializationMode's cereal early-out hands you the payload offset as onelea. Every cerealized packet tests its mode and, on the cereal branch, tail-callsserialize(ctx, this + N, stream)-Nis where the payload sub-object starts, on both platforms, with no size arithmetic. It sits above the manual branch in the same function, so one decompile gives the payload base and (point 5) every member offset and cereal name.- A payload sub-object and the same members declared flat differ on Linux
only, and by exactly the base's tail padding. Itanium allocates derived
members from
dsize(base)(44 forPacket), so a flat leadinguint8_tlands at 44 while a payload sub-object - alignment 8 - is forced to 48; every later member andsizeofshift 8. MSVC never reuses base tail padding, so Windows measures the same either way and its size assert passes on a wrong model. A packet whose assert holds on Windows but is 8 short on Linux is a missing payload wrapper: model the payload, never an#ifdef __linux__filler member.UpdateSoftEnumPacketis the worked case (120/112 real, 120/104 flat).sizeofonly shifts when nothing re-aligns it: a later 8-aligned member (astd::bitset, a pointer) absorbs the 4 bytes and both size asserts pass while every member before it is 4 bytes low on Linux - and the flat model's first member then readsPacket's uninitialised tail padding, which is where "the client sends NaN rotations" reports come from (PlayerAuthInputPacket @ 1.26.40, 232/232 either way). - A Linux-only assert failure at bump time can be a pre-existing
mis-model. Adding size asserts during a bump surfaces every historic
modelling error at once. Grep-date the
<Name>Payloadstring (point 7) before attributing anything to the new release -UpdateSoftEnumPacketwas identical in 1.26.32/36/40 and only the new assert was new. - A member absent from the registration can still be a WRITE GATE. A
migration drops the old bit-field from the wire but keeps it as the member
that decides which optionals are engaged: each per-member getter tests
payload->type & <bit>and each setter ORs the bit back in on read. So the registered name list is the wire, not the live member set. The two packet-specific accessor thunks - the 3rd and 4thlea rax, sub_...of a member's registration block - carry the member offset, the element stride and the presence condition in one decompile; read them before concluding a member is dead. (ClientboundMapItemDataPacket @ 1.26.40: 13 registered members,typenot among them, every one of them gated by it.) - Two vectors serialized as separate cereal members can still be parallel
arrays. Independent length prefixes are not independent semantics - check
the producer. BDS's own constructor filling both in one loop over a
vector<pair<K,V>>is the proof that the receiver pairs them by index, and any Endstone code that rewrites one must rewrite the other. The reader never validates the lengths, so a mismatch is a well-formed packet that breaks the client instead of erroring. - The PAYLOAD's alignment decides the Linux shift, not the first member's.
An all-scalar payload (
SetSpawnPositionPacketPayload- twoNetworkBlockPositions and twointenums) has alignment 4, so Itanium packs it intoPacket's tail padding at 44 - exactly where the flat members land. Inline and payload models are then byte-identical on both platforms and the missing wrapper is cosmetic, not a bug. Point 12's "forced to 48" holds only when the payload contains an 8-aligned member. Settle it by dumping both models in one-fdump-record-layoutsrun per target rather than reasoning from the leading member. The displacement is also confined to the members before the first 8-aligned one - that member re-aligns to the same offset in both models - so a flat declaration can be wrong in its leading scalars and right from there on (BossEventPacket@ 1.26.40: two leadingconst ints at 44/48 instead of 48/52,boss_idat 56 either way,sizeof152 either way). createPacket'smake_sharedis a free default-member map - and theSerializationMode's initial value is not alwaysCerealOnly. The factory stores every member, PODs included, so one decompile gives the sentinel defaults (dimension_type = -1,spawn_block_pos = INT_MIN^3) and the mode. A packet that still carries a hand-writtenwritebranch is constructedSideBySide_LogOnMismatch(1), so a header initialisedCerealOnlymisstates BDS (SetSpawnPositionPacket/SubClientLoginPacket@ 1.26.40, both 1).operator new's immediate in the same function issizeofplus the control block - 16 on MSVC, 24 on libc++.- When the factory calls an out-of-line ctor taking the payload BY VALUE,
that ctor is the complete member map -
dst - srcis the payload base.make_sharedzeroes an N-byte payload on the stack and passes it, so every store reads*(this + off + BASE) = *(payload + off)and the constant difference is the payload offset (48), while the store widths give each member's size:mov dword= the packed leading bools,movups+movpairs = a 24-byte vector move, an 8-byte move plus a0x80000000000000-bit teardown =Bedrock::StaticOptimizedString. It works on aCerealOnlypacket, which has no hand-writtenwrite(point 5) to read. The trailingoperator deleteloop in the same function hands you the element stride of any vector member for free (ResourcePacksInfoPacket@ 1.26.40:PackInfoData184/160). - Two things in the registration path that waste time. The per-member
accessor thunks are generic entt meta wrappers for a plainly-stored member -
they walk the meta node and carry no literal displacement, so unlike point 14's
gated accessors they tell you nothing about the offset; go to the constructor
instead. And a
mov [rsp+10h], rdx; push...; lea rbp, [rdx+N]block sitting just before the function is an MSVC EH funclet re-establishing the parent frame -Nis a stack offset, not point 11's payload-baselea. - A shortfall of exactly 8, EQUAL on both platforms, is the payload's tail
padding - every member is right except the packet-level
SerializationMode. When the flat model's leading member is already 8-aligned (a polymorphicTypedClientNetId, a struct holding one 8-byte seed) point 16's head displacement never happens and the only divergence is at the end: BDS rounds the payload sub-object up to its own alignment before appending the mode, while a flat declaration packs the mode into the trailingbool's padding.InventoryTransactionPacket @ 1.26.40is 376/336 flat against 384/344 real, with onlyserialization_modemisplaced (372/332). Read the direction: a shortfall on one platform is a head misalignment, an equal shortfall on both is a missing wrapper at the tail. - Name an unnamed payload member from the offset of ITS OWN first owned
member. A payload dtor's last call takes whatever sits at payload+0;
decompile that callee, find its first non-trivial teardown, and match that one
number against the last PDB-bearing release's record for the type you expect.
StartGamePacketPayload's tail call lands on a dtor whose first owned member is aJson::Valueat +32 - exactlyLevelSettings::mFlatWorldOptionsin the 1.26.32 PDB - which names an 1100-byte sub-object with no symbol and proves its POD head did not move at the same time. Corroborate on the callee's xref count: a type used binary-wide has dozens, a packet-local struct has three. The same walk settles point 9 either way -StartGamePacket @ 1.26.40kept its declaration order (teardown offsets map 1:1 onto the 1.26.32 order) even thoughsettingsis the sixth field on the wire, because registration order is chosen independently of declaration order. cerealizer<T>::bindis the exact wire field list, and it tells a variant TAG from a real member by how each is bound. Reach it by grepping the binary for the type-name literal ("RemoveScore") and taking the lone data xref. A genuine data member goes throughcereal::BasicFactory<T>::_bindInternalCommonand carries ameta_setter_<T>_&T::m<Field>_; the discriminant is instead bound straight throughbasic_meta_factory::data+custom(MemberDescriptor)withtraits = is_static|is_constand a constant-returning getter namedmeta_getter_<T>_<N>_,<N>being that case's tag value. Each entry also installs aTypeSchema<...>vftable that spells the member's type outright (TypeSchema<std::optional<std::string>>).
- A registered
is_static|is_constmember IS still written to the wire - as cereal's name-coded string. It is a constant, not a stored field, and it is absent from the C++ struct, but the serializer emits it all the same. So a cerealised variant entry carries the discriminant TWICE: first theuvarint32case index, then that constant again as a length-prefixed name.SetScorePacket @ 1.26.44on the wire is01 | 00 | 06 "remove" | e0 02 | 01 | 01 | 04 "demo"= count, index 0, name"remove", scoreboard id, the new keyed bool, the optional's presence bool, the objective name. - Do not read "the struct has no member" as "the wire has no field".
bedrock-headers shows
RemoveScoreas justmScoreboardId+mObjectiveName, and protocol-docs prints the constant twice (packet-levelswitchplus a per-case"Action"of the enum's string type). Both are accurate; neither means one tag.bedrock-protocol'saction: SomeEnum = field(type=str)was the model that had it right, andfield(type=str)generating a name-coded serializer is the point, not an artifact. - The name-coded string is not the C++ enumerator spelling. The binary
carries
"ChangeFakePlayer"as a literal, but the wire writes lowercase"remove". Casing is per enum ([[project_bds_cereal_enum_wire_names]]), so never derive it - and when only transforming a payload, read the case from the index and copy the name through verbatim rather than interpreting it. - This one is only settleable by capturing a live packet. The cerealizer binding, protocol-docs and bedrock-headers were each individually consistent with a single one-byte tag, and all three readings were wrong. A 4-case variant plus a short name string still looks plausible at a glance, so budget for a capture before shipping a byte-level rewrite.
- In a CEREALISED packet a variant tag is always a
uvarint32, whatever the enum's underlying type says. This is a cereal rule, not a universal one: a hand-writtenwritepicks its own width and often spells the same discriminant as a plainuint8. So establish which kind of packet it is before encoding anything - presence inprotocol-docsIS the test: if a packet is not documented there, it is not cerealised, and its tag width comes from reading its manualwrite. Everything from protocol 2168 onward is cerealised; earlier releases are a mix. - The trap is that it is invisible: a 4-case variant's tag fits in one byte,
so reading a cerealised tag as
uint8is byte-identical and stays correct until a variant grows past 128 cases. Encode to the packet's actual kind rather than to what the bytes happen to look like today.
Worked example: BossEventPacket @ 1.26.32 - migrated to cereal-only;
color/overlay narrowed 4B->1B, both darken/fog bools removed, a
SerializationMode member added at the tail, sizeof held at 168/152 purely by
alignment. Proven entirely from the manager copy (movzx byte pairs + the
trailing SerializationMode store) and operator new(0xA8/0x98); the vtable was
unchanged (cereal migration is data-only - verify it separately,
Detecting vtable changes). See [[project_bosseventpacket_changed_1_26_32]];
confirm by asm, never by size ([[feedback_decompile_to_confirm]]).
Detecting event-variant changes (std::variant traps)
Gameplay events reach Endstone through a std::variant wrapper -
EventVariantImpl<Details::ValueOrRef<const E>...> (aliased ConstEventVariant/
MutableEventVariant): LevelGameplayEvent<void>,
PlayerGameplayEvent<CoordinatorResult>, etc. Endstone hooks a
Script<...>GameplayHandler::handleEventN and calls event.visit(visitor)
(std::visit). Drift in the variant's alternatives surfaces as
std::_Throw_bad_variant_access at that event.visit line - the C++ EH
0xe06d7363 -> std::terminate -> STATUS_FATAL_APP_EXIT 0x40000015, and the
process exits via crashpad's NoDump code 0xFFFF7001 (or a heap status). It only
fires when a covered event actually dispatches: on an idle, pack-less server the
script gameplay handler may not run until shutdown's LevelStartLeaveGameEvent,
so "runs fine, crashes on stop" is the classic symptom - not a startup
crash, even when the size-driving alternative (e.g. a scripting world-init event)
never fires.
MSVC std::variant layout is the whole game. Storage = max(sizeof(alt));
the discriminant _Which (1 byte for <=255 alts) sits immediately after the
storage (offset = sizeof(variant) - pad). Each ValueOrRef<const T> is
union{const T*; const T value} + bool is_pointer_, so its size is
max(8,sizeof(T))+1 padded and is_pointer_ lands at the value size. std::visit
reads _Which; an out-of-range value (or the 0xFF valueless sentinel) throws -
NatVis prints [valueless_by_exception]. Two drifts cause it; separate them
before fixing:
- Size drift - an alternative's struct changed size, moving
max(sizeof(alt))and therefore the_Whichoffset. Endstone (built with the old size) reads_Whichat the stale offset -> garbage index. Latent for every event. - Set drift - an alternative inserted/removed/reordered. Size can be unchanged (if the size driver is untouched), but a new alternative's index is out of range for Endstone's shorter variant, and any reorder mis-maps existing indices.
Read the truth from the BDS dispatch + extractor, no symbols needed. The
caller of the hooked handleEvent (the frame above your hook in the crash stack;
crash-report addresses are object-relative, so imagebase + RVA lands directly in
the DB) does, right after the vcall:
movsx rdx, byte ptr [rcx+OFF] ; OFF = real _Which offset = real storage size
inc rdx
call <extractor>(dest, _Which+1, event)OFF= the real storage size - compare to Endstone'ssizeof(variant)-1/BEDROCK_STATIC_ASSERT_SIZE; differ => size drift.- The extractor switches on
_Which+1, onecaseper alternative (case 0is the valueless stub) - case count = alternative count; compare to theConstEventVariant<...>arity => set drift. - Each case's leading
cmp byte ptr [r8+K], 0/1 ; jz ; mov r8,[r8]is theValueOrRefptr-vs-value test, soK= that alternative's value size; the largestKacross cases is the size driver - the only alternative that sets the variant size. - Fingerprint each case to name it:
_InterlockedIncrementon a control block counts theWeakRef/NonOwnerPointermembers;memcpy+ a0xAAAA...ABsize-multiply =std::string/std::vectorfields. Match (size, ref count, field shape) to Endstone's known alternatives - matches are existing (read off their new index), unmatched cases are the inserts (their case index = the insertion point). An event whose case carries noWeakRefrefcount cannot be an existing player/actor event that holds one - that mismatch is how you prove a removal, not just a move. - Diff against the previous named DB's extractor - same routine, but its
per-alternative copy-ctors carry demangled
ValueOrRef<...>type names, so it labels every old slot; diffing the two case lists names the existing slots and isolates the new ones.
Fix. Mirror the new alternative list (order included) in the
ConstEventVariant<...>. The visitor matches by type (std::is_same_v), so
the events Endstone actually handles only need to be present as the right type -
their exact index is not memorised, but the total count and the size driver
must be right. Exploit that:
- A non-size-driver alternative may be left empty (
struct E {};) or a named placeholder (struct UnknownEvent0 {};) - it still occupies one slot, and an empty struct'sValueOrRef(16 B) never drives the size. This restores the count / order /_Whichoffset without baking in an unconfirmed layout. - Only the size driver (largest
K) needs a byte-exact body; confirm withBEDROCK_STATIC_ASSERT_SIZE. After a size-drift fix the driver can become a different alternative - recompute which one it is. - On Linux, read the alternative count straight off the visit table - and bound
it by the next rip-referenced address. libc++'s
std::visitcompiles tomov eax, dword [event+OFF]; cmp rax, -1; je <valueless>; lea rcx,[rip+TABLE]; call [rcx+rax*8], soOFFis the discriminant offset (a 4-byte index;-1isvariant_npos) andTABLEholds one thunk per alternative. Do NOT end the table at "the next qword is not a code pointer" - the generated thunks of neighbouring tables sit directly after it and the run reads far too long (an 8-alternative table measured 51). The terminator is the next address in the image that any rip-relativeleareferences;(next - TABLE)/8is the count. Validate the method on the PREVIOUS binary first - it must reproduce the count Endstone already declares. - A vtable change on a
Script*GameplayHandlerdoes not by itself mean variant drift. BDS adds whole newhandleEventoverloads (a new event category) far more often than it changes an existing variant. Compare the discriminant offset and the bounded alternative count of the hooked slot across versions before touching an event list; an insertion below the hooked ordinal changes nothing.vhook::create<N>patchesvtable[N]by raw index, so only insertions at or aboveNcan break a hook. - Confirm live: breakpoint the hooked
handleEvent, copybyte [event+OFF_real]intobyte [event+OFF_endstone], and continue - if thebad_variant_accessthen vanishes across a full start/stop, the offset/size mismatch is proven (cdb on the injectedbedrock_server.exe; lldb needs a matchingpython3xx.dll). Confirm by decompile, never size alone ([[feedback_decompile_to_confirm]]).
Placeholders and discipline
When you cannot name a type/signature precisely, placeholder it - but keep the size / order / slot-count exact and document the observed fingerprint:
- A member you can't name:
void *unknown_<off>_;(8 bytes). For a 24-byteNonOwnerPointer<?>useBedrock::NonOwnerPointer<void *>- usevoid*notvoidasT(NonOwnerPointer<void>fails to compile viavoid& operator*();void*&is fine). A 64-byte unknown map is any realstd::unordered_map<K,V>. - A vtable slot you can't name: one
virtual void unknown<slot>() = 0;. - Mark every made-up name with
// TODO(fixme): check the nameon the line above it. Without it a plausible-looking invented name is indistinguishable from a verified one, and the person holding the real headers has no way to know which to check. Grep the marker to get the full back-fill list. - Never carry an inferred name as if it were read from a symbol. A shape -
"returns a nested optional", "returns a null unique_ptr" - proves the slot and
the return kind, never the identity. Name it
unknown<slot>and put the shape in the comment; do not promote a guess into the header. - Comment every placeholder with offset + fingerprint for later identification,
and keep Endstone's
lower_case_naming. When headers later arrive (Scenario A), back-fill the real types and cross-validate.
Finish
Re-run the dumper until all symbols resolve (or remaining 0s are understood).
Build (cmake --build --preset conan-release), run tests, add a CHANGELOG entry
("Added support for BDS X.Y.Z"), commit, open the PR.
Gotchas (shared)
Whole-binary diff: clearing a hotfix release without IDA
When the new release is a hotfix off the current one, lief alone can prove
the bump is ABI-free in minutes - no IDA database, no header diff, no PDB. Both
binaries are already in ~/.bedrock_server/<platform>/; unzip the old and new
side by side and run these three checks. They also catch the Scenario-B trap
where a stale pattern silently matches a different function, so run them on
any pattern-only table however the version was resolved.
- RVA-delta bands. Sort every resolved offset by RVA and print
new - oldagainst the previous committed table. A rebuild with a handful of insertions gives a small set of deltas that only ever step up with RVA (1.26.36: Linux +192/+208/+240 code, +880/+896.bss; Windows +64 then +576). A negative delta, a wild outlier, or a flipped relative order is a pattern that matched the wrong place.- Section bases move too. A Linux symbol's delta is section shift +
intra-section shift, so compare
.text/.bssvirtual addresses between the two ELFs before calling a delta anomalous (1.26.36.textitself moved +192, so a +48 insertion inside.textreads as +240 on every symbol). - On PE, sections are page-aligned, so
.textgrowing within its current page count leaves.dataunmoved - a global's delta of exactly 0 while code shifted is correct, not a stale hit.
- Section bases move too. A Linux symbol's delta is section shift +
intra-section shift, so compare
- Body comparison names the function without a name. For each entry read
~256 bytes at the new offset in the new binary and at the old offset in
the old one, and require >=90% byte equality. Same function = near-identical
prologue and body (only relocated displacements differ); a pattern that
collided with a different function scores far below. This is the cheap
stand-in for decompile-and-compare ([[feedback_decompile_to_confirm]]) when
the function is untouched by the release. Data symbols in
.bsshave no file content - expect them to read back as unreadable and check their section membership and delta instead. - Vtable-invariant sweep proves no slot moved anywhere in the binary -
far stronger than checking the classes you happen to suspect:
- Linux: walk
.data.rel.roas a qword array and compare index-by-index, accepting only the known section shifts as differences. If the section stays index-aligned end to end, no pointer was inserted or removed, so no vtable changed length and none was added or removed. Residual divergences are plain data - a packed version constant (0x0001_00<patch>_001a_0001is major/minor/patch, and it flipping21->24is just 26.33 -> 26.36), counters, build hashes. - Windows:
.rdatainterleaves vtables with string/float blobs, so a changed blob breaks index alignment without meaning anything. Classify each qword instead (points into.text/ into.rdata/ zero / plain data) and compare the total count of code pointers. Equal counts across versions = no vtable slot added or removed binary-wide. - When the counts DON'T match, the global number proves nothing - diff
per class by RTTI before believing it. Key every vtable by its Itanium
typeinfo name (
_ZTS...-> typeinfo -> address points with a non-relocatedoffset_to_top == 0), record each class's slot-run lengths, and compare the two name-keyed maps. That turns "18 code pointers disappeared, something moved" into a named set difference. 1.26.44 read -18 on Linux / -7 on Windows and the per-class diff over ~45k classes showed zero classes changed shape: the delta was three whole classes going away (a cereal constraint helper plus its and one other__shared_ptr_emplace<T>control-block vtable). Template instantiations appearing and vanishing is routine churn and moves the global count without any class changing - only the per-class diff separates the two. - A vanished
__shared_ptr_emplace<T>is also a free lead: it means nothing callsmake_shared<T>any more, which usually pairs with a member somewhere changingshared_ptr<T>->unique_ptr<T>. Go looking for it rather than waiting for the crash.
- Linux: walk
- Protocol version, statically.
SharedConstants::NetworkProtocolVersionis compared directly insideServerNetworkHandler::_validateLoginPacket, whose offset the table already holds. Disassemble it (capstone) and collectcmpimmediates; the protocol version is in that set. Run it against the previous version first to confirm the method reproduces the known-good value, then the new one. Counting raw dwords of a candidate value across.textdoes not work - the value collides with thousands of unrelated constants.- Do not filter candidates by "plausible successor". Mojang re-bases the
numbering, it does not only increment: 1.26.36 -> 1.26.40 went 1001 ->
2168. Bound the
cmpscan loosely and let the structure pick the answer, not the magnitude. Anything downstream assuming small monotonic steps (version-gate tables, DSLsince=boundaries) needs review after a bump. - Two symbol-free corroborations, worth running when the jump looks odd:
scan executable segments for the compare idiom itself
(
cmp [reg+0x30], imm32+jge+ the disconnect-reason store, immediate wildcarded), and read the RakNet MOTD builder (xref"MCPE;", then theoperator<<that stringifies the protocol field). Both land on the same constant from completely different code.
- Do not filter candidates by "plausible successor". Mojang re-bases the
numbering, it does not only increment: 1.26.36 -> 1.26.40 went 1001 ->
2168. Bound the
Do not skip the source-side bump because the offsets moved uniformly:
shared_constants.h (PatchVersion, and NetworkProtocolVersion if it moved)
and the README Minecraft badge still need updating.
Real BDS changes that still need no src/bedrock/ action
Endstone deliberately models only a subset. Rule these out before editing:
- Additive enum values beyond Endstone's declared subset - the underlying width is unchanged, so values added past Endstone's range need nothing.
- A class Endstone truncates with
// ...(e.g.NetEventCallback) - virtuals/members added in the undeclared tail are already covered; touch nothing. (One added in the middle of the declared part still needs handling.) - An empty-stubbed type (
struct X {};, e.g.NetworkPeer::NetworkStatus) - internal changes need no action. - New free functions or non-virtual methods Endstone does not declare - no layout or vtable impact.
- A newly added
std::lock_guard. NewMSVCP140!_Mtx_lock/_Mtx_unlockcalls bracketing existing code, with theINT_MAX->INT_MAX - 1recursion sentinel andstd::_Throw_Cpp_error(5)/(6), is MSVC's inlinedstd::mutex::lock(). Threading hardening around unchanged logic - it shifts the stack frame and nothing else.
Tooling
- lief's free build cannot read PDBs -
lief.pdb.load()fails with "DebugInfo are not available for this build". The Windows path shells out topdbtool. - dbghelp is too slow - a ~2 GB BDS PDB takes minutes;
pdbtool dump ... psitakes ~25 s. - PowerShell
>writes UTF-16 - a redirectedpdbtooldump won't grep with ASCII patterns; capture via the script's subprocess (UTF-8) oriconv -f UTF-16 -t UTF-8first. - Windows symbol offsets are RVAs:
RVA = section RVA + symbol offset. - Some symbols are never public - function-local statics, lambdas, some data -
and never appear in
pdbtool ... psi. The--pdbpath handles this: any entry it cannot resolve by name falls back to scanning that entry'spatternin the server binary. Such an entry just needs a valid bytepattern; itsnamecan be an arbitrary unique key. - When a function has no usable symbol at all (an overload dropped from the PDB
publics, or a lambda) - resolve a callee instead: reimplement the function in
src/bedrock/from the decompilation andBEDROCK_CALLa function it calls that does have a stable symbol. Verify the reimplementation; never guess. - A function inlined on one platform but out-of-line on the other has a real
symbol on only one side. Split resolution with
#ifdef. If the inlined-away function is hooked (not just called),#ifdef-calling the original does not help - relocate the hook on that platform to the still-out-of-line caller it was inlined into and reconstruct the trigger condition there. - To recover a renamed Windows symbol (re-signed, not removed), dump every
public once with
pdbtool --quiet dump <pdb> psiand grep for the scope (?<method>@<Class>@@); the surviving line is the new mangled name. Verify against the signature before copying it into the config.
Re-extracting a stale Linux pattern
A "Linux only" miss is a stale byte pattern; the function still exists.
- Prefer a prologue pattern over a call-site one. A call-site pattern
(
E8 ?? ?? ?? ??+rip_relative/rip_offset) resolves via acalldisplacement, so it breaks whenever the caller changes - even when the target is untouched (one release breaks a whole cluster). Re-anchor on the function's own prologue wherever it is not inlined. - Converting a call-site entry to a prologue entry: delete
rip_relativeandrip_offset. A prologue pattern's match offset already is the function; leavingrip_relative = truemakes the dumper RIP-decode prologue bytes and resolve to a silently-wrong, non-zero address - worse than a0miss. - A prologue miss vs a call-site miss differ: prologue = the function's
own codegen changed (callee-saved register set, argument shuffle); call-site =
a caller changed. Neither implies a signature change. Confirm against the
Windows mangled name before assuming the config
namemust change. - Cross-version dump to triage. Run the dumper against the previous
version (copy the config, set the old
version; the output path is fixed, sogit checkoutthe regenerated table afterward). A pattern that matches the previous but not the new version went stale at this bump; one that misses both was already wrong. - RVA-delta plausibility. Cross-check resolved offsets against the previous table: deltas should be positive (the binary grows) and rise broadly monotonically with RVA. A negative delta, a wild outlier, or a flipped relative order flags a pattern that matched the wrong location. Sub-MB local non-monotonic wiggles are normal linker noise. This only holds while the linker keeps its layout. When a release reshuffles link order, correct entries move megabytes backwards and swap relative order wholesale - see Verifying a pattern-only table. Treat deltas as a weak hint, never an arbiter.
- A stale pattern can match the wrong function entirely (not just miss) - see Scenario B - Finding a new symbol / offset for the two-way verification (function start + decompile-confirm) and the stale-PDB interaction.
Verifying a pattern-only table (no PDB)
When a release ships no PDB, every Windows entry is resolved by byte pattern, so none is name-verified. A stale pattern does not only miss - it can match a different function with a similar prologue and yield a plausible, wrong offset. Verify the whole table against the previous version's table plus both binaries.
- Check pattern uniqueness first.
find_signatureusesre.search, so it takes the first match and never checks for a second. Re-run each pattern withre.finditerand count both matches and distinct resolved addresses. Many matches collapsing to one address is harmless; n matches resolving to n addresses means the answer was decided by.textordering alone - that is where wrong offsets come from. Also run the new patterns against the previous binary and assert each lands on the old known-good offset; that validates the pattern, though not the first-match choice in the new binary. .pdatais the decisive function-start check on Windows - run it on every entry first. Parse the exception directory (data dir 3) as 12-byte RUNTIME_FUNCTION records and exact-match the RVA against aBeginAddress. If it is not aBeginAddress, the offset is not a function start. Follow chained continuation records (UNW_FLAG_CHAININFO,flags = byte0 >> 3, next.Begin == cur.End) to get the true extent for the later checks.- Referenced-string sets are the strongest cheap identity signal - strings
are version-stable while code is not. Resolve every RIP-relative reference in
the extent into
.rdata/.dataand read ASCII/UTF-16. A genuine match gives a near-identical or superset string set; large disjointness is a false match. Filter the ubiquitous allocator strings (void *operator new(size_t),We failed to allocate %zu bytes.,pointer || size == 0, theMemoryOperators.cpppath) - they appear in almost every function. - For string-less functions, walk the call closure 2-4 deep and compare that string set. This is what catches the hard cases; a body with zero strings clears nothing on its own.
- A normalized instruction signature beats raw byte equality - mnemonic plus operand register class, with all immediates and displacements dropped. It survives relocation and re-register-allocation, unlike raw bytes.
- But structural similarity measures the pattern's function class, not
identity. A pattern that selects a family (e.g. thread-safe-static singleton
accessors) makes every candidate score ~0.96 against the old function. Always
pair a high ratio with a semantic discriminator - the marker string named in
the pattern's own locate recipe (the numbered comment block above its
[[signatures]]entry) is exactly the right one. - Judge by behaviour, not size. Extent length and instruction count drift legitimately with inlining; do not flag on size alone.
- Some tiny tail-call thunks legitimately have no
.pdataentry (e.g. an argument shuffle plusjmp). Before calling it a defect, check whether the previous known-good version also lacked one; if so, verify by following thejmpand comparing the target instead. - Data symbols are not functions. Globals have no
.pdata, no prologue, no strings, and need not be 16-byte aligned. Verify by section membership plus reference population: scan.textfor every 4-byte window whereR + 4 + disp == target, group hits by containing.pdatafunction, and check that the dominant referencer carries the locate recipe's marker string and that the referencer count is stable across versions. Random hits are negligible over a ~170 MB.text, so the scan is essentially noise-free.
The Linux side needs the same audit - the shipped ELF is stripped (no .symtab,
only a tiny .dynsym), so every Linux entry is pattern-resolved too.
- The FDE table is the Linux
.pdata- and it is exact..eh_frame_hdrholds a sorted binary-search table of one<i4 initial_location, i4 fde_ptr>pair per function, datarel to the hdr vaddr; the FDE then gives the exact length (pc_beginpcrel at fde+8,pc_rangeat fde+12). Exact-match the offset against aninitial_location, and validate the decode by assertingpc_beginequals the table value. Exact extents also remove the tail-call problem, so no flow-following heuristic is needed. - The dangerous miss is a match inside the right function. When a prologue
gains one instruction (an extra
push), a stale pattern matches a few bytes late and the bytes there still decode as a plausible prologue - only the FDE/.pdataexact-match catches it. Alignment is a useful secondary hint. - RTTI vtable slots are the only name-anchored check on Linux. Rebuild
.data.rel.rofrom itsR_X86_64_RELATIVEaddends, key vtables by their Itanium typeinfo name, and confirm the offset sits at the same slot of the same class in both binaries. Covers roughly a quarter of a table for free. - A vtable slot that moved is not automatically wrong. Prove it: the class's primary vtable must have grown by exactly the shift, and the neighbouring slots must align old-to-new (difflib over per-slot function fingerprints) with old slot N mapping to new slot N+k.
- Expand strings caller-side, not just callee-side. Index every
E8rel32 in.textwith numpy for an exact caller map (millions of calls in seconds), then compare the callers' string sets. This is what decides entries that have no strings of their own and no callee strings either. - Weight strings by distinctiveness rather than maintaining a filter list. Sample a few thousand random functions, count string frequency, and treat anything appearing in >0.2% of them as generic. Matching only on boilerplate is nearly no evidence.
- Report
old - newandnew - oldseparately. A release adding strings or callees is routine; a genuine match losing its old anchors is not. Collapsing to one Jaccard hides the direction that matters. - Cross-platform agreement separates "wrong offset" from "changed body". A stale-pattern collision is a per-platform accident. A symbol that scores low structurally on Windows and Linux independently means the code changed; low on one platform only points at that platform's offset.
.bssdata symbols have no file content (SHT_NOBITS) - nothing to byte-verify, no prologue, no FDE, no vtable. Use the reference scan, with the ELF wrinkle that the displacement is not always last: run theu32(p) + p == (T - text_lo - 4 - imm_len) mod 2^32comparison forimm_lenin 0/1/2/4, then re-decode each hit to confirm. Compare the reference count and per-function distribution rather than strings - the static-init blobs that reference such globals legitimately change content between releases.- A function reachable only through thunks looks unreachable. Zero direct
callers plus zero vtable slots can just mean an adjustor thunk (small FDE, call
site at +3) or a 5-byte
jmpisland just before the entry. IndexE9rel32 too; the thunk topology itself should mirror 1:1 across versions.
BDS itself
- BDS sometimes does structural refactors, not just signature tweaks - e.g.
1.26.20 split packets into a
<Name>Payloadstruct pluscerealizer/serializespecializations, and re-parentedConnectionRequestunderBaseConnectionRequest. Real design work, not a mechanical edit. When one packet shows the<Name>Payloadsplit, assume everynetwork/packet/*got it;inventory_slot_packet.his a good reference. - A function missing from the PDB publics may have been inlined, not removed.
- Whole-program optimization can drop the unused
thisfrom a non-virtual member that never touches it - every argument shifts down one integer register (Linux: first real arg movesrsi->rdi; spotmov <reg>, rdiwhere the old prologue hadmov <reg>, rsi). Mirror by declaring the functionstaticinsrc/bedrock/(#ifdef-split if only one platform optimized it). This changes__FUNCDNAME__(Itanium loses the cv-qualifier_ZNK...->_ZN...; MSVC loses theBconst code) - thenamemust change, not just the pattern. - BDS sometimes promotes a type alias to a concrete type - e.g. 1.26.20
turned
using DimensionType = AutomaticID<Dimension, int>;into astruct DimensionType. The mangled name of every symbol taking it changes with no logic change (a Windows miss). The fix is structural: make Endstone's type a realstructtoo (mirror the layout), replace every use, delete the orphaned alias header. Editing only the confignameis not enough -__FUNCDNAME__comes from the declaration. - The network peer chain is spliced, not hooked.
onNewIncomingConnectionswaps BDS's compressed and batched peers for Endstone subclasses, so BDS's own code for those layers never runs. Each bump: check the size asserts, the field offsets BDS writes through the connection's typed weak pointers (the inlined compression-enable store in the network-settings handler, found by its 10 MiB0xA00000immediate, and the async-flush flag store inenableAsyncFlush), the chain order inNetworkConnection's ctor, and diff the decompiledsendPacket/_receivePacket/flushagainst the reimplementations - a BDS behaviour change there must be mirrored by hand. - The async worker pool's wake threshold is written at startup. Each bump:
check that
WorkerPool::min_backlog_duration_seconds_is still at+0xfc(Windows) /+0xcc(Linux), that both pool ctors still store0.05F(0x3D4CCCCD), and thatMinecraftWorkerPool::ASYNCstill resolves. A miss logs one warning at startup and leaves vanilla's threshold. - Removing a class is a multi-file cascade: also remove its
src/endstone/runtime/bedrock_hooks/*.cpphook, theCMakeLists.txtsource entries, the[[signatures]]blocks in both configs, the generatedsrc/bedrock/symbols/*.hentries, and any dependent declaration.grepthe class name afterward to confirm nothing dangles.
Symbol table is not optional for the build
get_symbol()isconstevaland throws when a name is absent; the dumper drops every0-valued entry. So an unresolved symbol any TU consumes viaBEDROCK_CALL/BEDROCK_VAR/BEDROCK_CTORis a hard compile error (C7595: call to immediate function is not a constant expression), not a silently-disabled hook. You must resolve every consumed symbol before the build goes green - porting alone never produces a buildable tree.- When a hooked function's signature changes, its
__FUNCDNAME__changes, so the oldnamein the configs goes stale - update it and re-dump. - Temporary verification trick. To compile-check the port before symbols are
finished, temporarily comment out the
if offset == 0: continueskip inwrite_symbols_headerinscripts/dump_symbols.pyso every entry (incl.0s) lands in the header;get_symbolthen finds every key and the build goes green, surfacing real ABI/source errors. Revert before committing - the dumper must keep dropping0s so a genuinely missing symbol stays a loud error.
Resuming a partially-done bump branch
- Don't trust that an earlier stage is finished. A prior commit may have ported
only some files of a stage (e.g. some
network/packet/*but not all) and left.cppfiles referencing the pre-refactor shape. Build early; the first compile pass surfaces these gaps.
Adding a pure virtual makes every embedder abstract
- A pure virtual added to a base class propagates:
field type '...' is an abstract classat every by-value member, far from the edit. A pure virtual destructor never does this - the implicitly-declared derived destructor overrides it. Any other pure virtual needs an explicit override somewhere down the chain. - Declare the override where the headers put it, not where it is convenient. The
DWARF dump for a class defined inside a
.cpplands inbedrock-headers/src/.../<Name>.cpp, not the matching.h- grep both. - A declared-but-undefined virtual is a link error only if the vtable is emitted,
which for these class templates needs a constructor or destructor to be
odr-used. Two ways to check before paying for a build:
llvm-nmthe object files a previous build already left in the build tree and grep for the mangled type name. No symbol in any.objmeans nothing forces emission.- Write a probe
.cppthat constructs the type, compile it with-c, andllvm-nm -uit. This proves the check is sensitive, and it lists the other declaration-only symbols the same vtable already needs. If those already exist in a tree that links, the new declaration adds no risk.
- To compile a single TU without touching the build tree, lift the flags from a
FAILED:line in the build log (not the repo-rootcompile_commands.json, which can be stale) and use clang-cl/Zs; add-ferror-limit=0so unrelated breakage does not hide your error.
Record findings here
Append durable, version-independent lessons to the matching section (shared Gotchas, or the scenario that owns the technique). Keep per-version specifics (which overload was chosen, what replaced a removed function) in commit messages and the CHANGELOG, not here.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

