Design RFC: 26.x GUI 2D-transform migration (PoseStack to Matrix3x2fStack)
Status: Phase 0 shipped (1.3.0-snapshot.2, immediate-chain peephole); Phases 1-3 proposed. Owner: unassigned. Target: a dedicated 1.x minor.
Update (Phase 0 shipped). The immediate
guiGraphics.pose().pushPose()/.popPose()peephole is implemented incom.retromod.shim.common.Gui2DTransformMigrationand wired into the CLItransformJarand the runtimeForgeModTransformer(NeoForge/Forge, gated 26.1+). It turned out the dominant push/pop pattern is the IMMEDIATE chain (thepose()call adjacent to the void op, result consumed on the spot), which is a self-contained two-instruction peephole and needs NONE of the dataflow below - no taint, no local retyping. Verified on Jade (16 of 20 push/popPose migrated; 0 new ASM CheckClassAdapter errors). The dataflow engine below is still required for the STORED pattern (PoseStack ps = guiGraphics.pose(); ps.pushPose(), seen in AppleSkin/JEI) and for the arg-carrying ops (translate/scale/mulPose), which Phase 0 deliberately leaves untouched.Calibration for the stored-pattern phase (measured on the corpus). AppleSkin’s overlay uses only
pushPose/popPose/translate/scale(2/2/2/2) with nolast()ormulPose, so it is FULLY coverable by Phases 1 below with zero text-path work. JEI is mostly coverable (popPose24 /pushPose22 /translate17 /scale4) with a smalllast()tail (3 sites) that stays on the Phase 2 text-render cluster. So the stored-pattern +translate/scaleslice (nomulPose, nolast()) is the high-value first increment: it clears AppleSkin entirely and the bulk of JEI.Implementation notes from a spike (do not repeat these mistakes). A stored-pattern engine was prototyped on
SourceInterpreter+ a taint fixpoint and then withdrawn unshipped, because the transform moves pixels and cannot be visually verified in this (headless) environment - shipping it without an in-game screenshot pass would be irresponsible. The spike surfaced two non-obvious traps that the real implementation must handle, and which make this bigger than it looks:
SourceInterpreterlocal sources point at theastore, not thepose()call. For a stored stack,frame.getLocal(n).insnsat anaload nis the set ofastoreinstructions that reach that point, NOT the originatingpose()call. So a taint fixpoint has to trace two hops: anastore“stores pose” iff its stored value’s producers are all pose; a local “holds pose” iff every reachingastorestores pose; analoadproduces pose iff its local holds pose. You need aMap<producer-insn, Set<pose()-call>>(“poseSrc”) carried through the fixpoint, because the thing you ultimately RETYPE (pose()’s return descriptor) is thepose()call, and the receiver of an op is anaload/dup, several hops removed from it. Retyping the immediate producer (theaload) is meaningless (it has no descriptor) - a bug the first draft had.- Escapes must poison the
pose()SOURCE, not the immediate producer. If any pose-tainted value is consumed by a non-handled use (passed to any other method as receiver-or-arg, stored to a field, returned, array-stored), the originatingpose()call(s) it derives from must be marked un-migratable and left as a real 3DPoseStack, otherwise the escape site gets aMatrix3x2fStackwhere aPoseStackis required. Apose()value is only legal as the RECEIVER of a handled op; everything else is an escape.The safety net is real and worth stating: re-emit under
COMPUTE_FRAMESand return the ORIGINAL bytes if it throws, plus a corpusCheckClassAdapter.verifygate. Together these guarantee the pass can only emit frame-valid bytecode or no-op - it cannot ship structurally broken classes. What they do NOT guarantee is pixel-correctness, which is exactly why the in-game screenshot pass (step 3 of Verification strategy) is the true acceptance gate and why this slice was not shipped blind.
TL;DR
26.x split GUI rendering off the 3D PoseStack onto a 2D org.joml.Matrix3x2fStack, and renamed GuiGraphics to GuiGraphicsExtractor. A 1.21.1 GUI mod that does guiGraphics.pose().pushPose() / translate() / scale() / mulPose() no longer links, but PoseStack still exists for 3D world rendering, so a type-blind PoseStack -> Matrix3x2fStack redirect would corrupt 3D code. Doing this correctly needs a small intraprocedural dataflow pass that taints the PoseStack values which originate from GuiGraphics(Extractor).pose() and rewrites only those to the 2D API. This is a multi-day pass, not a redirect-table entry. A naive accessor-insertion redirect was tried in the corpus-audit work and reverted: it produced a double pose() on the real Jade mod because the receiver is the pose()-derived stack, not the GuiGraphics.
Background: what 26.x changed
Verified against the local 26.1 and 26.2 jars:
net/minecraft/client/gui/GuiGraphicswas renamed toGuiGraphicsExtractor(already handled by a class redirect inCommon_1_21_11_to_26_1_ClassMoves).GuiGraphics.pose()used to returncom/mojang/blaze3d/vertex/PoseStack(the 3D stack). On 26.x,GuiGraphicsExtractor.pose()returnsorg/joml/Matrix3x2fStack(a 2D transform stack).PoseStackSTILL EXISTS on 26.2 (used by world/entity rendering). It is not going away.- The 2D stack’s op surface differs by name and arity:
- push/pop:
PoseStack.pushPose()V/popPose()V->Matrix3x2fStack.pushMatrix()/popMatrix()(both return the stack fluently, so the void call becomes a call + POP). - translate/scale:
PoseStack.translate(FFF)V/scale(FFF)V(3D) ->Matrix3x2f.translate(FF)/scale(FF)(2D: drop the z arg; the fluent return is popped). PoseStack.translate(DDD)V->translate(FF)also needs adouble -> floatnarrowing on x,y and a pop of z.mulPose(Quaternionf)V(3D rotation) ->Matrix3x2f.rotate(F)(2D: extract the Z-axis angle; see Risks - this is a GUI-common approximation, not a general 3D->2D map).last()/last().pose()(extracting aMatrix4ffor text/vertex draw) has no direct 2D analogue; it feeds the text-rendering path, a separate sub-cluster.
- push/pop:
The core problem: PoseStack is now overloaded
The bytecode type com/mojang/blaze3d/vertex/PoseStack now means two different things:
- A 2D GUI stack, when the value came from
GuiGraphics(Extractor).pose(). - The 3D world stack, everywhere else (entity/level rendering, still a real
PoseStack).
A redirect keyed on (owner=PoseStack, name, desc) cannot tell them apart, so it would rewrite 3D world-render calls too and corrupt them. The distinction is a dataflow property of each value, not of the type. That is why this is not a redirect and needs an analysis pass.
Current state (already broken, independent of the reverted attempt)
Today the transform leaves GUI pose usage mis-resolved: with no explicit PoseStack -> * move, the fuzzy method resolver guesses GuiGraphics.pose()’s new form and picks a wrong descriptor (observed: GuiGraphicsExtractor.pose() retyped to return GuiGraphicsExtractor), and the downstream pushPose()/translate()/... calls stay unresolved. So GUI mods that apply pose transforms are already broken on 26.2. This RFC replaces that with a correct, targeted migration.
Design: intraprocedural “2D-PoseStack” taint pass
A new analysis pass (sits alongside the existing frame passes, gated to 1.21.5+/26.x, both loaders; Fabric via the intermediary names of the same members).
Sources (a value becomes tainted “2D”)
- The return value of
GuiGraphics(Extractor).pose()(intermediary: the same method on Fabric). - Optional later: a
PoseStackparameter of a recognized GUI-render override whose caller is known to pass the 2D stack. Deferred to Phase 3 (interprocedural); Phase 0-2 are intraprocedural only.
Propagation (within one method body)
Standard forward dataflow over the operand stack + locals:
astore nof a tainted value taints local n;aload nof a tainted local yields a tainted value.dup/dup_x1/… copy taint.- A fluent op on a tainted receiver (e.g.
pushMatrix()returns the same stack) yields a tainted value again. - Merge at control-flow joins: a value is tainted only if tainted on all in-edges (conservative; a mixed merge is treated as untainted and left as 3D, which is safe - it just declines to migrate).
Use ASM’s Analyzer with a custom Interpreter/Value carrying a one-bit taint, or reuse the existing frame infrastructure. Intraprocedural keeps it tractable and covers the overwhelmingly common case (a GUI method grabs guiGraphics.pose() into a local and transforms it inline).
Rewrite (only on tainted receivers)
When a call site’s receiver is a tainted 2D PoseStack, rewrite per the op-mapping table below: change the owner to Matrix3x2fStack/Matrix3x2f, rename the method, transform the args (drop trailing z, narrow double->float where needed), and pop the now-fluent return if the old method was void. Also retype the backing local variable and any checkcast/field descriptor to Matrix3x2fStack so verification holds. Untainted PoseStack values are emitted unchanged.
Op-mapping table (tainted PoseStack -> Matrix3x2fStack)
| old (PoseStack, 3D) | new (2D) | arg transform | return |
|---|---|---|---|
pushPose()V | Matrix3x2fStack.pushMatrix() | none | POP |
popPose()V | Matrix3x2fStack.popMatrix() | none | POP |
translate(FFF)V | Matrix3x2f.translate(FF) | drop z | POP |
translate(DDD)V | Matrix3x2f.translate(FF) | d2f on x,y; drop z | POP |
scale(FFF)V | Matrix3x2f.scale(FF) | drop z | POP |
mulPose(Quaternionf)V | Matrix3x2f.rotate(F) via a Z-angle helper | quat -> Z angle (lossy) | POP |
last()/last().pose() | text-render path (Phase 2 sub-cluster) | n/a | n/a |
Scope boundaries / non-goals
- This RFC is the pose-stack track. The audit also surfaced a parallel, largely-mechanical cluster: the
GuiGraphicsDRAWING API signature changes (blit,text/drawString,setColor,flush, ~30 methods onGuiGraphicsExtractor). Those have a fixedGuiGraphicsreceiver (no dataflow needed) and are better handled with ordinary method redirects / re-signatures in a separate, parallel track. Do not fold them in here. - 3D world/entity
PoseStackusage is explicitly out of scope and must remain untouched.
Risks and known limits
- Interprocedural gaps. A mod that stashes the 2D stack in a field or passes it across methods won’t be tainted by the intraprocedural pass; those call sites stay unmigrated (documented partial coverage, not a crash - they remain unresolved exactly as today).
mulPoseis lossy. A general 3D quaternion has no 2D rotation; only Z-axis rotations map. GUI rotations are almost always Z, so extract the Z angle and accept the approximation; guard by only migratingmulPosewhen the value is tainted 2D.- Verification is the gate, and it is visual. Pose transforms move pixels. Frame-valid bytecode is necessary but not sufficient; the real gate is a screenshot/behaviour check in-game.
- A wrong taint would corrupt 3D. Mitigated by construction: sources are GUI-only, merges are conservative (untainted-on-doubt), and 3D
PoseStackvalues are never a source.
Verification strategy
- Unit tests: the taint interpreter on synthetic methods (source -> local -> op; a control-flow merge; a decoy 3D
PoseStackparam that must stay untouched). - Real-mod transform +
CheckClassAdapter.verifyon the output (catches frame/stack errors without a running MC). - Headless load, then in-game on Jade / JEI / AppleSkin: confirm their HUD/overlays render in the right place (before/after screenshots). This is the true acceptance gate.
Phasing and rough effort
- Phase 0 (~1-2 days): the taint engine (source =
pose(), intraprocedural propagation, local retyping) + the no-arg ops (pushPose/popPose). Smallest end-to-end slice; verifiable on Jade. - Phase 1 (~1 day):
translate/scale(drop z, d2f). - Phase 2 (~1-2 days):
mulPoseZ-angle helper + thelast()/Matrix4ftext-render path. - Phase 3 (optional): interprocedural (field/param flow) if real mods need it.
Total: a multi-day dedicated project. The pass is the bulk; each op after the first is incremental.
Alternatives considered and rejected
- Blanket
PoseStack -> Matrix3x2fStackclass redirect. Corrupts 3D world rendering. Rejected. - Accessor-insertion redirect (
x.pushPose()->x.pose().pushMatrix()). Tried and reverted: the real receiver is already the pose()-derived stack, so it double-appliespose(). Rejected. - A Matrix3x2f-backed
PoseStackpolyfill (duck-type both APIs). You cannot substitute the realPoseStackclass (MC’s own 3D code references it), so per-value substitution still needs the dataflow to know which values to swap. Rejected as a standalone approach. - Coarse heuristic (“any
PoseStackinside aScreensubclass is 2D”). Too coarse: GUIs render real 3D entities too (inventory mob previews use a genuine 3DPoseStack). Rejected.