Skip to content

Merge Iris-on-Metal with upstream master safety fixes - #13

Merged
EternityQwQ merged 85 commits into
EternityQwQ:masterfrom
21Z121Z1:iris-on-metal
Aug 2, 2026
Merged

Merge Iris-on-Metal with upstream master safety fixes#13
EternityQwQ merged 85 commits into
EternityQwQ:masterfrom
21Z121Z1:iris-on-metal

Conversation

@21Z121Z1

@21Z121Z1 21Z121Z1 commented Aug 2, 2026

Copy link
Copy Markdown

Summary

Merge the pushed 21Z121Z1/MetalUniversal:iris-on-metal implementation with EternityQwQ/MetalUniversal:master using Iris as the conflict authority.

The merge commit is 14ea4e774fde7ae100118dca470c9ffc4877c4c9 with parents 695bd7079e783c1433168f916216e56e414771e3 (Iris) and 3bf3011aedf204c5a57cd14c047e09adea277ccf (upstream master).

Conflict resolution

  • Preserved Iris 1.11.2/Sodium 0.9.1 dependency and semantic/render-contract source set.
  • Preserved Iris uniform lifecycle, Metal 4, contract tracing, generic vertex input, and index-buffer paths.
  • Retained upstream buffer safety in the Iris architecture: non-empty/size/max-allocation checks, null native-handle defenses, bounded dynamic backing pool, bounded LRU buffer pool, and indirect-draw overflow guards.
  • Reordered MetalDevice device-info initialization so the merged max-allocation check is valid before Iris creates its generic vertex buffer.

Verification

Passed on the final source tree and Apple Silicon GPU:

  • ./gradlew test verifyProductionJarIsolation
  • metalMrtBackendIntegrationTest
  • metalComputeBackendIntegrationTest
  • metalIrisTargetsIntegrationTest
  • metalMrtSmokeTest
  • metal4PipelineSmokeTest
  • metal4PipelinePathTest
  • macOS/iOS native and SPIRV-Cross builds

Production JAR isolation passed. Final artifact identity:

  • source commit: 14ea4e774fde7ae100118dca470c9ffc4877c4c9
  • source tree: b47dd0d72d219ab2daa1a748741f474aa77843ae
  • production JAR SHA-256: 9c1592f609d7514c7197167498d20a1b0615e532d5abca7c353a2676545e1ff7
  • macOS native SHA-256: aaa2790ec4ae4225bad637682d3647d9dde207f9bf65ab89ced2214266bcbde1

The real shader-pack translation gate was run and failed closed because this clean worktree has no non-redistributable run/shaderpacks/*.zip fixtures. No old JAR, worktree, or evidence was substituted. Therefore this PR does not claim complete Iris conformance.

Metallum Dev and others added 30 commits July 26, 2026 18:29
Snapshot of the working tree as extracted from MinecraftMetal(1).zip
contents: Metal backend, MetalFX spatial/temporal, MRT backend,
frame-generation scaffold (fail-closed). No Iris support yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- iris_metalfx_implementation_audit.md: worktree-verified audit answering
  the 9 audit questions (buildable w/ JDK25, FFM bridge, MRT E2E receipt,
  validation harness capability, rollout mining incl. 6th session handoff)
- iris-audit/: Blaze3D 26.2 x metallum coverage table, real Iris
  1.11.2+26.2 jar call-surface audit (GL renderer, needs Sodium 0.9.1,
  no SPIR-V), 20-item feature matrix, build/run runbook
- iris_on_metal_implementation_plan.md: two-phase plan; integration
  decision resolved to Form B (Iris semantic layer) with B0-B3 staging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the mod-private capability layer Iris needs but vanilla Blaze3D 26.2
lacks, across all three layers:

- Swift ABI: compute encoder (pipeline/bind/dispatch/indirect/fences),
  MTLDevice.makeComputePipelineState, blit generateMipmaps,
  create_sampler_v2 with depth-compare function
- FFM bridge: optional downcalls + capability probes
  (supportsComputeAbi/GenerateMipmaps/SamplerCompare), stale-dylib
  fail-closed errors
- Java: MTLComputeCommandEncoder, MetalComputePipeline (GLSL compute ->
  shaderc SPIR-V -> SPIRV-Cross MSL with reflected local_size),
  MetalComputePass (explicit-index binding contract; joins the global
  MTLFence hazard chain), MetalCommandEncoder.createComputePass/
  generateMipmaps + pending-clear pre-flush, MetalGpuSampler compare ctor
- Hardening: writeToBuffer/writeToTexture reject heap ByteBuffers on the
  staging path (previously SIGBUS via Unsafe.copyMemory)

Verified: metalComputeBackendIntegrationTest 10/10 GPU readback tests
through the production backend (absolute/relative/indirect dispatch,
SSBO chains, imageLoad/Store, render<->compute ordering, mip-2 downsample
content, shadow-compare semantics) with MTL_DEBUG_LAYER=1 and
MTL_SHADER_VALIDATION=1; test+metalMrtBackendIntegrationTest regression
green. Suite wired into check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
metalMrtBackendIntegrationTest now also covers: 4 attachments,
non-contiguous logical draw-buffer mapping (locations 0/2/5 with inert
null slots — Iris DRAWBUFFERS:025 shape), depth+MRT in one pass with
depth-content readback (z=0.25 through DepthStencilState), and
resize-recreate freshness (no stale storage reuse). 14/14 green under
MTL_DEBUG_LAYER=1 + MTL_SHADER_VALIDATION=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tests

- IrisMetalPingPongTargets: main/alt pairs with BufferFlipper semantics
  (flip/isFlipped/flippedAtLeastOnce/snapshot/restore), feedback-loop
  guard, resize resets state+storage
- IrisMetalRenderTargets: colortex set + depthtex0/1/2 with GPU copy
  capture points (no-translucents / no-hand) and a DRAWBUFFERS-shaped
  compact write-descriptor factory over RenderPassDescriptor
- IrisMetalShadowTargets: shadowtex0/1 + flip-aware shadowcolor set,
  pack-config square resize, main-pass state isolation

Verified: metalIrisTargetsIntegrationTest 6/6 content-level GPU readback
tests (three-pass ping-pong both-sides assertions, snapshot rewind,
depth capture trio 0.25/0.5/0.75, shadow depth+color+isolation+resize,
flip reset on resize) under MTL_DEBUG_LAYER=1 + MTL_SHADER_VALIDATION=1.
Wired into check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Sodium mc26.2-0.9.0 -> 0.9.1 (Iris binary requirement); existing sodium
  mixins compile+runtime verified; real-client smoke A: Metal backend,
  ~4min in-world, clean log
- Iris 1.11.2+26.2-fabric added to the dev classpath (loads as a mod)
- Dormancy shim set (com.metallum.mixin.iris.*, gated on Iris present +
  default backend, live-backend checked at runtime): cancels Iris's GL
  entry points (RenderSystem-init chain, GLDebug incl. runtime
  push/pop/name, IrisRenderSystem.initRenderer/supportsSSBO,
  IrisSamplers.initRenderer, VanillaRenderingPipeline clip-control,
  loadShaderpack); GlStateManager._getInteger answers conservative
  constants during dormant clinit probes; MetalGpuTexture overrides the
  mixin-injected iris$getGlId with synthetic ids for Iris's per-texture
  tracking hook
- MetalFxManager.reactiveTexture gains RENDER_ATTACHMENT usage (deferred
  clear materializes it as a color target; Metal validation aborted)
- Validation docs: smoke A/B1-B7 evidence, vanilla 26.2 startedCleanly
  crash-fallback mechanism (forces OpenGL after startup crash), harness
  discipline; acceptance report: Phase 1 FAIL (8/12 gates), Phase 2 not
  started per spec ordering

Verified: smoke B7 (Metal + Sodium 0.9.1 + Iris dormant) 28s to world,
90s sustained in-world rendering, 0 crash markers; regression green:
test + MRT 14/14 + compute 10/10 + iris-targets 6/6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erf audit

- MetalIrisShaderCompiler: TransformPatcher output -> std140 loose-uniform
  wrapping + hostile-identifier rename -> shaderc (auto-bind/auto-locations,
  450-core retry lane) -> SPIRV-Cross MSL (production options) -> device
  MTLLibrary compile
- metalIrisShaderTranslationTest: full overworld ProgramSet matrix over real
  packs; BSL 10.1.3 52/52 + Potato 44/44 stages green incl. shadowcomp
  compute; report + failure dumps under build/reports/metallum
- headless-load shadows (test classpath only): Iris / StandardMacros /
  IrisRenderSystem; extractIrisNestedJars puts Iris's embedded
  glsl-transformer/jcpp/antlr on the test runtime classpath
- smoke C: BSL installed + enabled in iris.properties; Metal backend joins
  world in 29s, 90s sustained, 0 crash markers, dormancy holds, sentinels
  healthy
- docs: validation L2 matrix + smoke C, acceptance increments (phase-1
  verdict unchanged: fail), runbook fixtures, plan B2 status,
  new metal_performance_audit.md (fence serialization, blit encoder churn,
  present-path notes; measure-first plan)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
S1/S2/S3 完成并验证,S5 代码落地未冒烟。判定见
docs/iris-audit/b2-1-design-handoff.md(活文档,S4/S6 有可直接照做的实现规格)。

编译线(已验证):
- MetalIrisShaderCompiler 新增 translateSodiumTerrain/linkPatchedPair:
  TransformPatcher.patchSodium → 松散 uniform 收进 std140 MetallumIrisUniforms
  块(两 stage 注入同一份块文本,避免同一 binding 上布局不一致)→ 出 GLSL,
  由库存链(vanilla GlslCompiler → IntermediaryShaderModule.rebind → SPIRV-Cross)
  接手。std140 偏移表在测试里对着 SPIRV-Cross 反射逐项校验,不靠推断。
- IrisMetalPipelineOverrides:按 IrisPipelines.getPipeline 的字节码判定 sodium
  管线的 solid/cutout/translucent,懒构建合成 RenderPipeline(XHFP 顶点格式、
  DRAWBUFFERS 决定的 colorTargets、sodium 自身 BindGroupLayout 逐字复制 +
  只追加包新增的名字)。全链失败一律 fail-open 回落原生编译。
- MetalDevice 两处 computeIfAbsent 前置查询覆盖注册表。

MetalCrossShaderCompiler:按槽宽重排 varying location(根因修复)。
库存链给 stage 接口变量分配 location 是“一个变量一个槽”,不计类型占用;
光影包的矩阵 varying(Potato 的 out mat2 / flat out mat4x3)按列各占一个槽,
于是后面的变量落进前一个的区间,MSL 出现重复 [[user(locnN)]],MTLLibrary 编译
失败。新增 varyingLocationSpans(SPIRV-Cross 反射算槽宽)+ relocateVertexOutputs
/relocateFragmentInputs,两侧按同一起始 location 紧密重排;未连接的 fragment
输入排到 vertex 输出区之后并告警一次。原版路径 varying 全是标量/向量,重排结果
与原编号等价。

唤醒线(编译通过,未做游戏内验证,默认关):
- MetalIrisCompat.semanticLayerEnabled() / -Dmetallum.iris.semantic
- loadShaderpack 放行;Iris.createPipeline 重定向到 MetalWorldRenderingPipeline
  (extends VanillaRenderingPipeline,镜像 WorldRenderingSettings 置位含 XHFP
  顶点格式;失败回落 VanillaRenderingPipeline,绝不放行 GL 构造器)
- StandardMacros 的 GL 面假接成与离线矩阵同款的 pinned GL 4.6 环境

默认关的原因:S4(uniform 供给)与 S6(pass 资源预置)未做,开了会在首次地形
绘制抛 Missing uniform MetallumIrisUniforms。

验证:metalIrisShaderTranslationTest 全绿(B2-2 矩阵 + B2-1 terrain,
BSL/Potato × solid/cutout/translucent 6/6 PSO 有效);回归 test /
metalMrtBackendIntegrationTest / metalComputeBackendIntegrationTest /
metalIrisTargetsIntegrationTest 全绿。

阶段一验收维持不通过:真实渲染验证未做。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Recovery point taken before extending the deterministic client validation
with core/item acceptance frames. Captures the previously uncommitted
MetalEntityObjectPose reconstruction, the core/item motion-pipeline
acceptance, the itemMotionDrawsEncoded diagnostic split, the in-progress
cutout shimmer remediation, and their unit coverage.

No working-tree file contents were altered by taking this snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captures the interleaved working state of three sessions that were
editing this shared checkout simultaneously (Metal 4 migration, Iris
adaptation, frame-generation comparison). Serves as the common ancestor
for the per-session worktrees created next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
现在 -Dmetallum.iris.semantic 默认为 true(=false 为 kill switch),启用光影包
所需的前置条件已全部就位。

S4 IrisMetalUniformValues:按 S1 算出的 std140 布局逐名填 MetallumIrisUniforms。
矩阵/相机/时间/视口/天气等取真实帧状态(CapturedRenderingState + CelestialUniforms
的公开面),未覆盖的名字置零并每名一次 debug 日志(即扩面工作清单)。GPU buffer 懒
分配(注册发生在 pack 装载期,那时不保证拿得到 device),采样任何一环抛异常则降级为
中性帧并告警一次——每帧都跑的填值不该把客户端弄死。

S6a 资源 fallback:MetalRenderPass.pushDescriptor 在缺名时先问覆盖注册表再抛。
gtexture→sodium 的 u_BlockTex、lightmap→u_LightTex;其余(noisetex/shadowtex*/
gaux*/depthtex*)绑 1×1 占位——sampler2DShadow 必须给深度纹理+compare sampler,
绑彩色纹理是硬校验失败而不是错像素。非覆盖管线一律返回 null,真实的缺绑定照旧抛。

顺带修掉一个“测试全绿但画面全错”的 bug:sodium 的 u_RegionOffset/u_CurrentTime/
u_RegionID 在其 vsh 里是 push_constant 块(#else 分支才是松散 uniform,而 patchSodium
产出走的正是 #else),被我们的 wrapLooseUniforms 折进了包 uniform 块。MetalDrawContext
每 region 写的 20 字节 push_constants 因此永远到不了 shader,所有区块会塌到区域原点。
partitionSodiumPushConstants 把它们摘出来原样重新发射为 push_constant 块,per-draw
ABI 与原生 sodium 管线完全一致;类型/顺序对不上直接抛,不让它退化成几何错位。

teardown:注册表关闭时清 MetalDevice 管线缓存(覆盖 PSO 缓存在 sodium 自己的
RenderPipeline 对象上,会活得比 pipeline 长;不清则 reload 后仍用旧 pack 的 PSO)。

离线门新增 verifyUniformSupply:走完 PSO 的整张绑定表,断言每个非 sodium 提供的资源
都能被 fallback 解析(否则就是首次地形绘制必抛),并断言 gbufferModelView 的 16 个
float 确实写进了块的正确偏移。

验证:metalIrisShaderTranslationTest / test / metalMrtBackendIntegrationTest /
metalComputeBackendIntegrationTest / metalIrisTargetsIntegrationTest 全绿。

已知边界:DRAWBUFFERS 长度 >1 的 kind 仍走 sodium 原生(BSL solid/cutout 是 [0] 会
生效;BSL translucent 与 Potato 全部不生效),需 S6b 扩展地形 pass 附件。
未验证:任何游戏内运行(S7 冒烟未做)。阶段一验收维持不通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
三个真实阻塞,逐条日志栈定位(详见 handoff §6 迭代 4):

1. 放行 loadShaderpack 的注入是空操作 —— 字节码确认它在启动期只有一个调用点:
   Iris.onRenderSystemInit 的最后一句,而我们对该方法是无条件取消的。改成语义层
   开启时由 mixin 自己调 Iris.loadShaderpack()(try/catch:装载失败只让 currentPack
   留空,不能把渲染器初始化拖下水)。
2. ShaderPack.<init> → FeatureFlags.isUsable → IrisRenderSystem.supportsImageLoadStore
   → GL.getCapabilities() 抛 No GLCapabilities。把 supportsImageLoadStore/
   supportsBufferBlending/supportsCompute/supportsTesselation 一并假接为 false。
   全 false 是故意的:B2-1 只实现 gbuffer terrain,不能让包走 compute/image 分支;
   真要求这些特性的包被 Iris 正常拒绝,好过渲染错误。
3. VanillaRenderingPipeline 构造器调用虚方法 shouldDisableDirectionalShading(),
   此时子类 programSet 还没赋值 → NPE。覆写加 null 检查(超类构造期给 vanilla
   默认值),构造器改用 directives.isOldLighting() 直接算。

真实客户端结果(BSL 10.1.3, enableShaders=true):语义层激活、Profile: HIGH 解析、
Using shaderpack: bsl-shaders.zip、solid/cutout/translucent 三个 kind 全部转译成功、
semantic pipeline generation 1 online;到标题画面 0 崩溃,管线创建后无 ERROR。

注意 in-game 与离线的 drawBuffers 不同:离线是默认 profile,in-game 是 HIGH,
translucent 变成 [0,1]。S6b 的必要性取决于 profile,不能只看离线结果。

未验证:没有进世界,地形绘制是否真的命中覆盖、uniform/采样器 fallback 是否真的喂上、
画面是否出现 pack 着色,全部未知。阶段一验收维持不通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne data set archive, residency set

Implements the first two batches of appendix E of
MinecraftMetal_Metal4_Migration_Specs_2026-07-27.md. Every Metal 4 path is a
parallel branch behind a default-off switch; the Metal 3 path is untouched and
is what runs whenever a switch is off, the device or SDK lacks Metal 4, or the
descriptor cannot be translated.

- M1: metallum_metal4_supported folds the compile-time #available and the
  run-time supportsFamily(.metal4) into one answer. Switch metallum.opt.metal4.
- M2 step 0 (the hard precondition): new metal4PipelineSmokeTest proves an
  MTL4Compiler pipeline state binds to an ordinary Metal 3 render encoder, so
  M2 needs no encoder changes and does not have to wait for M7. Green. It also
  shows one MTL4 pipeline is valid in passes with and without a depth
  attachment, since MTL4RenderPipelineDescriptor has no depth/stencil format.
- M2a: NSMapTable weak-to-strong function -> library side table (MTLFunction
  does not expose its library, MTL4LibraryFunctionDescriptor requires it), plus
  the lazily built process-wide MTL4Compiler.
- M2b: MTLRenderPipelineDescriptor -> MTL4RenderPipelineDescriptor translation
  and a new branch in metallum_MTLDevice_makeRenderPipelineState. Switch
  metallum.opt.metal4Compiler.
- M2c archive half: MTL4PipelineDataSetSerializer replaces MTLBinaryArchive on
  the Metal 4 path, removing the "an archive loaded from disk can never be
  re-serialized" limit that forced S9A into read-only reuse. Java ABI unchanged;
  the archive is a sibling file (pso.mtl4archive) so flipping the switch does
  not cold-start the other mode's cache.
- M3: MTLResidencySet on the existing Metal 3 queue (macOS 15 / iOS 18, no
  Metal 4 needed). Switch metallum.opt.residencySet.

Synchronization layer: no fence or barrier semantics were changed. Nothing in
this commit touches waitForFence/updateFence, the split-fence path, or the
cross-queue event chain. The only submit-path change is a residency commit
immediately before commandBuffer.commit() in metallum_MTLCommandBuffer_commit
and _commitWithSignal, which is a no-op (one nil check) unless
metallum.opt.residencySet is on. M6/M7e are where this line will touch the 34
fence sites, and that will be rebased onto the integration branch first.

Three API truths that only running the code could reveal; all three are written
back into docs/mtl4-api-probe.swift, still typecheck-green on macosx26.0,
macosx14.0 and ios14.0:
- The spec's M2c pairing of .captureDescriptors with
  serializeAsArchiveAndFlush(url:) is wrong. configuration is an NS_OPTIONS mask
  selecting which serializer method is usable; the archive flush needs
  .captureBinaries. The wrong pairing compiles and throws nilError at run time,
  so the pipeline cache silently never lands. A typecheck-only probe cannot
  catch this class of error, because that file is never executed.
- A synchronous makeRenderPipelineState(descriptor:compilerTaskOptions:)
  overload exists, so lookupArchives does not force pipeline creation onto
  Swift concurrency.
- Metal returns the same MTLFunction object for an identical library source, so
  a "library not registered, must fall back" test that reuses the same MSL still
  hits the side table and proves nothing. Distinct sources are required.

Verification, all green: compileJava, compileTestJava, test, buildMacNative,
buildIOSNative, metalMrtSmokeTest, and two new tasks run with
MTL_DEBUG_LAYER=1 - metal4PipelineSmokeTest (API level) and
metal4PipelinePathTest (links the shipping module and drives the real exports:
capability gate agrees with supportsFamily, switch-on pipelines render
pixel-identically to switch-off, an unregistered library falls back cleanly
instead of returning nil, the archive flushes on both a cold and a warm launch,
and the residency set adds, excludes memoryless, and removes correctly).
buildIOSNative passing is what upgrades "no deployment-target bump needed" from
a typecheck claim to a real build result.

One-shot NSLog lines report which path ran ("Metal 4 pipeline path engaged" /
"... unavailable, using Metal 3: <reason>"). Falling back is by design never an
error, so without them a client run cannot distinguish the new path working
from every pipeline silently degrading.

Not implemented, deliberately: the flexible-PSO half of M2c
(metallum.opt.metal4FlexiblePso). The spec's mechanism has no variant matrix to
act on here - the six variants differ only in depth/stencil format, which the
translation already drops - and capturing the saving needs the Java depth
signature collapsed, which is unsafe while the Metal 4 branch can silently fall
back to a depth-less Metal 3 pipeline used in a depth pass. Recorded for
author decision in section 3 of the audit rather than worked around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
按高频集成协议,在 handoff 加 §4.4。共同祖先 ea2dfd4;本线迄今未动同步层
(fence/barrier/encoder 边界均未碰),唯一与 Metal 4 迁移线重叠的是
MetalRenderPass.pushDescriptor 的资源解析,且是纯加法(非覆盖管线返回 null
走原逻辑)。记忆里 MetalFxManager/Swift 冲突的老风险对本线已不成立——本线没改这些文件。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
缺口 2/3/4 各推进到部分达成:编译链+uniform/采样器供给已落地并有离线 GPU 证据
(6/6 PSO),真机客户端已验证 pack 装载→解析→转译→合成管线上线。但三项都卡在
同一件事——没有进世界,绘制期是否命中覆盖/画面/reload 全部未验证,故计数不变。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 个文件冲突。MetalFxManager/MetalCrossShaderCompiler/MetallumNative.swift 是机械的,
MetalDevice 需要把覆盖查询搬进 theirs 的 async prewarm 结构(两处,含后台那条)。

MetalCommandEncoder 是真语义冲突,不自己猜:本线 B0 的单 MTLFence 链 vs S10 的拆分
fence(transferFence/fence 按 MTLRenderStages 收窄),而 compute 编码器在拆分模型下
wait/update 哪条 fence 是 Metal 4 线的设计决定。答错=编译通过、跑得动、同步语义已坏。
本线在同步层的持仓是 compute 编码器 fence 语义,由 metalComputeBackendIntegrationTest
的三条有序性用例守着,合完必须重跑。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… resource

The frame graph compiler rejected a write to a Lifetime.EXTERNAL resource from
any stage other than FRAGMENT, BLIT or PRESENT. That rule was not grounded in
anything: a drawable can legitimately be written by a compute kernel, given
shaderWrite usage, and several composition designs do exactly that. The stage
check that remains — a pass may only use a resource from a stage the resource's
descriptor permits — already covers the real constraint, because a descriptor
that omits COMPUTE rejects a compute write on its own.

Keeping the rule would have rejected valid pipelines for no benefit. The
equivalent restriction was never added to the version pushed to the fork.

The frame graph and native ABI layers themselves landed on this branch's history
via fbff4d7, which snapshotted three sessions' concurrent work; this is the one
correction made after that snapshot. 93 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two follow-ups to the object-motion acceptance frames.

Pin the item's spin phase at its capture frame. ItemEntity.bobOffs is
randomised per entity and final, so rather than pinning the offset the
integer tick base absorbs it: getSpin is ageInTicks/20 + bobOffs, so
choosing tickCount at the capture frame fixes the rendered angle there in
every run. Two consecutive runs measured horizontal motion spread of 0.023
and 0.028 at frame 164 purely from that phase drift; pinning removes the
variance and keeps the capture off the face-on phase, where a block item's
visible face shares one depth and the spread carrying the rotation largely
collapses.

Clear pauseOnLostFocus before the level guard rather than inside
applyDeterministicWorldState, which only runs once a level frame has been
driven. Under Gradle the window usually opens unfocused and the pause
screen opens on the same frame the player joins, before that first level
frame; the paused unfocused client is then throttled to effectively zero
frames and the timeline never starts. Three runs on 2026-07-27 exited
reporting success having captured nothing. This does not fully close the
hole — the task still cannot tell "validated" from "never ran" — so the
Gradle-side run-state assertion is tracked separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Object motion measures 30-55% off the analytic magnitude (partial-tick and
limb-depth broadening, docs/metalfx-frame-generation.md), so a floor picked
directly under the observed spread is not safe: the lowest item spread seen
across runs (0.023) deflates to 0.0104 at the bottom of that envelope, under
the previous 0.012 floor. Lower it to 0.008.

This costs nothing in detection power. The regression the floor exists to
catch — losing the rotateY term in MetalEntityObjectPose.droppedItem —
leaves a translation-only field, which has no horizontal component to spread
at all, so it lands near 0.001 rather than merely lower. The floor still sits
an order of magnitude above that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
同步层改动:MetalCommandEncoder 的 compute 编码器 fence 归属。按 Metal 4 线给出的
依据(NativeState.transferFence 不变量:split-fence 下唯一在 transfer 链上的 Swift
编码器是 FG 输入 copy blit,其余一律留在传入的 render fence;M6 依赖表「compute 写→
render 读」记的是全量 wait/update 传入 fence),compute 在两种模式下都只 wait/update
render fence,不碰 transferFence。这也保证 M7e 迁移时它翻成 dispatch→fragment 的单一
生产者/消费者对,而不是两对。其余同步层代码(split fence 的 render/blit 分派、
waitRenderFences 的 MTLRenderStages 收窄)全部取集成分支侧,本线未改。

逐文件:
- MetalCommandEncoder:保留双方(theirs 的 encoderGeneration/waitRenderFences +
  本线的 compute 编码器),compute fence 按上述定论
- MetalDevice:取 theirs 的 async prewarm 结构;三条编译路径(render 线程按需/
  prewarm 后台/precompile)全部收敛到新的 compileWithIrisOverride 单一漏斗——
  漏掉后台那条会让预热抢先写入原生 PSO,Iris 覆盖静默失效
- MetalCrossShaderCompiler:保留双方(theirs 的 MSL 磁盘缓存 key + 本线的 varying
  槽宽重排),vertexAttributeFormats 维持包级可见
- MetalFxManager:取 theirs(代码等价,仅注释与换行不同)
- MetallumNative.swift:保留双方的 @_cdecl 段(合并时 HEAD 侧 compare-sampler
  函数被截断,已补回闭合括号)

回归全绿:metalComputeBackendIntegrationTest(守 compute fence 语义)/
metalMrtBackendIntegrationTest / metalIrisTargetsIntegrationTest /
metalIrisShaderTranslationTest / test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Object motion was produced only for pipelines whose vertex shader is
core/entity or core/item. Falling blocks and block entities render through
core/block, so they reached the interpolator with no object motion and their
movement was left to disocclusion rejection. This is the gap the frame
generation doc's Known limits and the attended QA checklist's §3 both name.

Verified against the 26.2 sources rather than assumed: RenderPipelines
BLOCK_SNIPPET builds SOLID_BLOCK, CUTOUT_BLOCK and TRANSLUCENT_BLOCK on
core/block with DefaultVertexFormat.BLOCK; RenderTypes builds
SOLID_MOVING_BLOCK, CUTOUT_MOVING_BLOCK and TRANSLUCENT_MOVING_BLOCK on those;
and FallingBlockRenderer.submit reaches them via submitMovingBlock.

The one thing that separates the family from core/entity is the clip transform.
core/entity uses ProjMat * ModelViewMat * Position; core/block uses
ProjMat * ModelViewMat * (Position + ModelOffset). Replaying block geometry with
the entity shader would produce motion vectors that look plausible and are
wrong, which is worse than producing none. ModelOffset lives in the shared
DynamicTransforms block that the source pipeline already binds, so the reduced
shader reads the same value the color pass used and needs no new uniform.

Attribute locations 0/1/2 hold Position, Color and UV0 in both formats, so the
explicit-location declaration carries over unchanged; BLOCK's ivec2 UV2 at
location 3 is left undeclared because the lightmap is not read.

Family is now an enum keyed on the vertex shader path, each member owning its
shader and its pipeline location prefix, and build() fails closed for a source
no family claims instead of silently using the entity shader.
blockMotionDrawsEncoded joins itemMotionDrawsEncoded as a subset counter of
motionDrawsEncoded, so a scene with a falling block in view and a zero there
localises the failure to this path.

Not yet reachable at runtime, deliberately: the sample carrier is attached in
ModelFeatureRenderer.prepareModel and ItemFeatureRenderer.prepareSubmit, and
moving blocks go through MovingBlockFeatureRenderer instead. Until that hook
exists MODEL_BUILD is null for a moving-block draw, shouldSplitEntityDraw
declines it, and blockMotionDrawsEncoded stays zero. Hooking
MovingBlockFeatureRenderer is the next unit; this commit is the family it will
feed.

98 tests pass. MetalMotionFamilyTest asserts each family's shader assets exist,
that the prefixes cannot collide, that only the block shader applies
ModelOffset, and that both shaders keep explicit attribute locations — the
mistake that silently discards every fragment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Third batch of appendix E: the migration's first MTL4 queue. The present thread
is the pilot because no Java ABI crosses it, one command buffer carries at most
an interpolator encode plus a three-vertex copy, the binding surface is one
texture and one sampler, and it touches no MTLFence. Switch
metallum.opt.metal4Present, default off, gated on metal4Compiler because the
MTL4 frame interpolator is built from an MTL4Compiler.

Synchronization layer: the main queue is not touched, and no fence semantics
change. What moves is only the *wait* side of the existing cross-queue
MTLSharedEvent: the main Metal 3 queue keeps calling encodeSignalEvent exactly
as before, while the present queue waits with MTL4CommandQueue.waitForEvent
instead of MTLCommandBuffer.encodeWaitForEvent. Shared events cross the
Metal 3 / Metal 4 boundary, which is what makes this pilot possible in
isolation. Metal 4 fences are same-queue only, so a pilot that used fences
would have collided with the main queue's fence chain immediately - that is why
this path was chosen and not another. Nothing here overlaps the 34 main-queue
fence sites, which stay for M6/M7e.

- New Metal4PresentPath owns only the Metal 4 mechanics: queue, one reusable
  command buffer, a two-deep allocator ring (maxOutstandingFrames is 1, and one
  allocator would be wrong because reset() reclaims memory the GPU may still be
  reading), a 1-texture/1-sampler argument table with initializeBindings, and a
  residency set plus the layer's read-only drawable-tracking set.
- present(_:) dispatches two ways; the entire Metal 3 branch below the dispatch
  is unmodified. presentMetal4 duplicates the lifecycle, deadline and diagnostic
  bookkeeping on purpose rather than factoring it out, so the Metal 3 path stays
  as it was.
- installTextureSet is the single choke point every rebuild path funnels
  through, so the residency republish hook lives there. A resize additionally
  rebuilds the MTL4 interpolator, and clears metal4Path with it on failure so
  dispatch reverts to Metal 3 rather than running with a stale interpolator.
- MTL4CommandBufferFeedback has no status, only error, so success is
  error == nil. It routes into the existing handlePresentGPUCompletion, which
  keeps the failure path that advances readyEvent - without that the present
  thread would hang on a stale wait.
- Ordering differences that matter: the event wait is issued only once the frame
  is certain to be committed, so a frame dropped by the deadline check does not
  leave a wait on the queue timeline; and the reusable command buffer is closed
  on every exit path via abandonFrame().

Two API truths found by running the code, both now in docs/mtl4-api-probe.swift:
- MTL4CommandQueue.label is get-only, unlike MTLCommandQueue's. The spec's M4
  skeleton line `queue.label = "..."` does not compile; the label has to come
  from MTL4CommandQueueDescriptor, and that overload throws.
- A Metal 3 pipeline state binds to an MTL4 render encoder with its texture and
  sampler supplied by an argument table. This is the reverse of the M2 question
  and had to be checked separately, because the presenter's copy PSO is built by
  the ordinary Metal 3 factory. metal4PipelineSmokeTest now covers it, under
  MTL_DEBUG_LAYER.

Verification, all green: compileJava, compileTestJava, test, buildMacNative,
buildIOSNative, metalMrtSmokeTest, metalFrameGenerationLifecycleTest (9),
metal4PipelineSmokeTest and metal4PipelinePathTest. The path test now drives the
real Metal4PresentPath headlessly: the whole object graph builds, the MTL4 frame
interpolator factory returns non-nil on this device (had it returned nil the
present path would have fallen back to Metal 3 permanently and silently), the
copy encodes through the shipping encodeCopy, and the four-step
waitForDrawable / commit / signalDrawable / present handshake completes with a
correct pixel readback.

Not yet verified, environment-blocked: the visible-window pacing acceptance
(presentedTime non-zero, deadline misses not increasing, 10 minutes without
deadlock). New task metal4PresentValidation runs the existing presentation
validation harness with the Metal 4 path enabled, but that harness currently
fails identically on the Metal 3 baseline and on the integration branch with no
M4 code at all ("Expected at least 4 generated presentations, found 0"), because
it needs a WindowServer-composited visible window. It has to be re-run from an
interactive session before M4 can be called accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1. IrisMetalPlaceholderTextures 的深度占位纹理从未初始化(注释宣称的与代码做的相反)。
   Metal 新建纹理内容未定义(实践为 0),深度 0 时 LESS_EQUAL 的 sample_compare 对任何
   ref > 0 返回 0 —— 全场恒处于阴影,正好是注释宣称的反面。补 clearDepthTexture(1.0)。
   离线门只断言 fallback 非 null、不查内容,抓不到这个;不修会污染 S7 冒烟判读。

2. IrisMetalPipelineOverrides.compiledKinds / reportedPlaceholders 的并发安全。
   合并集成分支后 MetalDevice 有了后台 prewarm 线程,覆盖可能在渲染线程之外编译,
   而绘制期正在读这两个集合。改成 synchronizedMap / ConcurrentHashMap.newKeySet。
   这是合并新引入的,不是原有缺陷。

3. handoff 文档三处仍写「默认关」与「为什么默认关」,与 §4 ledger 末条自相矛盾,
   已修;并写明 S4/S6a 目前只有编译期证据——真机日志里 compiling terrain override
   一行都没有,运行期执行次数为 0。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…me close idempotent

Acts on a review of the M4 present pilot. Both changes are on the one path whose
failure mode is a permanently wedged present queue, so they are worth taking even
though the hazard was already avoided.

Synchronization layer: no semantic change. The main queue is still untouched and
no fence is involved; this only moves where the existing cross-queue
MTLSharedEvent wait is issued from, within the same thread and queue.

1. queue.waitForEvent moves inside submit(), and the standalone waitForReady is
   gone. Metal 4's queue wait is a queue-timeline operation that takes effect when
   called, unlike Metal 3's encodeWaitForEvent which is recorded into the command
   buffer and disappears with an unsubmitted buffer. present(_:) returns from four
   places after encoding and before commit, one of which — the deadline-miss guard
   — fires in normal operation. A wait issued before those returns would be an
   orphan nothing satisfies, and every later commit would queue behind it. The
   wait was already placed after all four returns, so behaviour is unchanged; what
   this removes is the ability to put it back in the wrong place, which a separate
   waitForReady(event:value:) invited. The reason it must sit there is now
   documented on submit() itself.

2. Frame closing is idempotent via an isRecording flag, so abandonFrame() can be
   called unconditionally on every exit path (all four already did) without the
   reusable command buffer being closed twice or closed when never opened.

New regression test in metal4PipelinePathTest for exactly the deadlock that
placement guards against: encode a frame and abandon it the way the deadline path
does, call abandonFrame twice to confirm idempotency, then require a real frame to
still encode, submit and complete within 5s. If the wait were issued early this
fails by timing out rather than by silently passing. This is the headless half of
the "deadline misses repeatedly, is the queue still alive" question that the
10-minute soak is meant to answer.

Three review points needed no change, recorded so they are not re-raised:
- drawPrimitives on an MTL4 encoder is drawPrimitives(primitiveType:...), not
  Metal 3's type:. The code already uses the MTL4 spelling.
- The Metal 3 copy pipeline on an MTL4 encoder with argument-table bindings was
  flagged as unverified. It is verified: metal4PipelineSmokeTest gained a case for
  that direction specifically, under MTL_DEBUG_LAYER, because the M2 step-0 case
  proves the opposite direction and does not cover it.
- NativeState.metal4PresentEnabled, the export, the Bridge downcall and
  MetalDevice.METAL4_PRESENT are all present and L1 is green.

Verification, all green: compileJava, compileTestJava, test, buildMacNative,
buildIOSNative, metalMrtSmokeTest, metal4PipelineSmokeTest, metal4PipelinePathTest
and metalFrameGenerationLifecycleTest (9).

Still environment-blocked, unchanged: the visible-window pacing acceptance
(metal4PresentValidation), which needs a WindowServer-composited window and fails
identically on the Metal 3 baseline here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fills in the remaining reachable rows of the object-motion coverage table
(docs/metalfx-frame-generation.md) so a regression in any single root
transform surfaces as its own failing frame rather than going unnoticed:

  living_turn   frames 180-191, capture 188 - pig body yaw, R_y(180 - bodyRot)
  arrow_turn    frames 192-203, capture 200 - R_y(yRot - 90) * R_z(xRot)
  minecart_rail frames 204-215, capture 212 - rail-sampled pose + hurt shake

Each object is held at a fixed world position and only rotated, so per-frame
translation is zero; measured object motion runs 30-55% off the analytic
value, so a scenario built on large per-frame translation would mostly be
measuring that error. The scenario swaps are staging only, fenced by a
history reset 8 frames ahead of each capture, and nothing validates a reveal
with them.

Every angle these three drive is read through a partialTick lerp of
old -> new, so pinning old == new leaves them free of any wall-clock term.
The minecart is the exception by construction: on a straight rail the
renderer re-derives orientation from the front/back rail samples and
discards the cart's own yaw, so the hurt shake is the only rotation
available without curving the track, and its wobble is asserted on the
axis-agnostic spread because it rolls about X rather than yawing.

Minecarts under the new behaviour are deliberately absent. That path needs
FeatureFlags.MINECART_IMPROVEMENTS, and the validation world enables only
minecraft:vanilla, so AbstractMinecart always constructs OldMinecartBehavior
here. Reaching it means changing the shared world's enabled features, which
would move every existing golden capture.

Expected captures 12 -> 15; the timeline now runs past the old 220-frame
ceiling, so the timeout moves to 300.

Compiles and the 93 unit tests pass, but these three frames have not yet
been through a client run - the validation client has been continuously
occupied by another session. Thresholds reuse the envelope already
calibrated against real item and boat captures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
进世界跑法:./gradlew runClient -Dmetallum.validation.world="New World"

证实(此前全是编译期推断):
- compiling terrain override CUTOUT ... → tryCompile 命中,discriminate 判定正确,
  合并后的 MetalDevice 编译漏斗正确
- pack sampler 'shadowtex0' ... bound a 1x1 shadow placeholder → S6a fallback 运行期
  真的被调到。「S4/S6a 运行期零次执行」这个状态结束。

崩溃:IllegalStateException: MTLRenderCommandEncoder is closed
  pushDescriptor → setTextureAndSampler,由 sodium 的 drawIndexedIndirect 触发。

根因是设计错误不是笔误:pushDescriptor 在渲染编码器活跃期间做惰性资源分配。
placeholders 的 createTexture/writeToTexture/clearDepthTexture,以及
IrisMetalUniformValues.slice 首次调用的 allocate+upload,都会 endEncoder() 去开
blit 编码器,把 pushDescriptor 正在写的 render 编码器关掉。惰性分配放在绘制路径上,
在这个后端里等于自毁编码器。

修法写进 handoff §6 迭代 5(四步,含一条防回归断言)。核心缺口:目前没有任何途径从
RenderSystem.getDevice()(返回 GpuDevice)拿到 MetalDevice,需要给 MetalDevice 加静态
当前设备引用,才能在 beginLevelRendering(编码器之外)完成预热。

本提交同时含与集成分支 wip/uncommitted-snapshot-2026-07-27 的第二次对齐(M4 present
走 MTL4 队列 + core/block motion family)。逐行确认:那 5 个提交未触碰 MetalRenderPass,
pushDescriptor 的 fallback 语义未受影响。回归 metalComputeBackendIntegrationTest /
metalIrisShaderTranslationTest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The task could report BUILD SUCCESSFUL having asserted nothing. If the client
window opens unfocused, the pause screen opens on the same frame the player
joins, the compositor throttles the paused unfocused client to roughly zero
frames and MetalValidationClient.beforeFrame never reaches a frame with a
non-null level. The scripted timeline never starts, no GPU readbacks are
captured, no run-state.json is written, and runClient still exits 0. Three runs
on 2026-07-27 did exactly that: run/logs/2026-07-27-6.log.gz and -7.log.gz have
no "GPU readback" lines yet the build succeeded.

c32f04b cleared pauseOnLostFocus before the level guard, which narrows the race
but cannot distinguish "validated and passed" from "never ran". Close that by
asserting on the artifact instead. finishRunState is the only writer of
run-state.json and the client calls minecraft.stop() only on its success path,
so an absent file is exactly the vacuous case.

runClient gains a doLast, active only under the existing
minecraftMetalFxClientValidation start-parameter guard, that requires the file
to exist, parse, report status "passed", and have completedGpuCaptures equal
expectedGpuCaptures. The expected count is read from the file rather than
hard-coded so the gate follows the client: the recorded golden baseline still
carries expectedGpuCaptures 10, so a literal 12 would have rejected it.

doLast rather than a finalizer because runClient is Loom's RunGameTask with
ignoreExitValue false, so the genuine-failure and 220-frame-timeout paths
already fail the task on a nonzero exit. The doLast is the net for the exit-0
cases, and also catches an exit-0 run that wrote status "failed".

Verified by extracting the doLast body into a standalone Gradle harness and
running it against ten cases, two of them real run-state.json files from the
tree: missing file, status failed, 8-of-12, the iris 9-of-8 failure,
passed-but-short-count, truncated JSON, missing fields, null status, 12/12, and
the 10/10 golden baseline. Task-graph attachment confirmed (three actions on
RunGameTask); both the guard-active and guard-inactive configuration paths
evaluate clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The core/block family existed but nothing reached it. The sample carrier is made
current in ModelFeatureRenderer.prepareModel and ItemFeatureRenderer.prepareSubmit,
and moving blocks go through MovingBlockFeatureRenderer, so MODEL_BUILD was null
for every moving-block draw, shouldSplitEntityDraw declined it and
blockMotionDrawsEncoded stayed zero. These are the two hooks that close that.

MovingBlockFeatureRenderer.Submit is constructed inside FallingBlockRenderer.submit,
which runs between beginEntitySubmission and endEntitySubmission, so the owner is
recorded there while it is still on the stack.

The build-time bracket needed a different shape from the entity and item families.
Those hook a private per-submit method; buildGroup inlines its per-submit work in
the loop body, so the submit exists only as a local. Rather than capture locals,
which pins the hook to a local variable table, the bracket wraps the one call that
emits a whole submit's geometry — ModelBlockRenderer.tesselateBlock — whose level
argument is the submit's own MovingBlockRenderState. That is also the key the
submit constructor recorded the owner under, so the two ends meet without either
needing the submit record. The wrap spans exactly the window in which
Group.getVertexBuilder and getOrAddDraw run, which are the points that split a
draw out and bind it to the sample.

A redirect rather than paired injections, because the sample has to be cleared
even if tesselation throws. A HEAD/RETURN pair around a call cannot express that,
and a leaked sample would attach this block's motion to whatever geometry is built
next — silently wrong motion vectors rather than none.

Moving blocks are keyed by render state and the owner is retained rather than
consumed: one block model can tesselate into the solid, cutout and translucent
render types, and beginFrame clears the map every frame so retaining cannot leak
across frames.

Verified statically, since this cannot be reached from a unit test: the redirect's
descriptor is byte-for-byte the invokevirtual in buildGroup's bytecode and occurs
there exactly once, the Submit constructor is (Matrix4fc, MovingBlockRenderState,
int) as the injection declares, and both mixins are registered — with
defaultRequire 1 a descriptor that stopped matching would fail at class load
rather than degrade quietly.

Still unproven at runtime: that blockMotionDrawsEncoded goes non-zero with a
falling block in view. The automated client validation drives an armour stand, and
adding a falling-block scenario means editing MetalValidationClient, which belongs
to the cutout-shimmer line. Recorded rather than done.

100 tests pass. MetallumMixinRegistrationTest now guards both directions of the
mixin config, the failure this commit would otherwise have been one json line away
from: a hook that compiles, ships and does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Known limits said falling blocks and block entities "would need a second
motion pipeline family" and reached the interpolator with no object motion. Half
of that is no longer true and the other half was imprecise, and this file is the
truth source the attended QA checklist sends a human to before judging the gate —
so an overstated gap costs QA time on something already covered, and a vague one
hides what is actually missing.

Object-motion coverage now states the family concept and both families in a table
with their format, clip position and replay shader, and names the two renderers
that emit core/block geometry: FallingBlockRenderer, which is covered, and
PistonHeadRenderer, which is not.

Known limits now says why the piston case is still open, which is not the shader
side: the BLOCK family replays it fine, but beginEntitySubmission is called only
from EntityRenderDispatcher.submit — verified, it has exactly one caller — and
BlockEntityRenderDispatcher is not hooked at all, so no submission window is open
when a block entity's submits are constructed. That single missing piece is what
blocks every block entity, not just the piston: a chest rendering entity-format
models through ModelFeatureRenderer is in a family that can replay it and has no
sample to replay it with.

It also records why the fix cannot be done from this line alone. The
current/previous pair has to come from the manager's own MetalMotionStateStore,
which commits only once a frame's output has been encoded; a second store kept
outside would commit on frames the manager discarded and then hand out a previous
transform that was never presented, which is the failure that store's design
comment exists to prevent. So it needs an entry point next to
MetalFxManager.captureEntityMotion, in a file the cutout-shimmer line owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@21Z121Z1
21Z121Z1 marked this pull request as ready for review August 2, 2026 01:14
Copilot AI review requested due to automatic review settings August 2, 2026 01:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@EternityQwQ
EternityQwQ merged commit de48cfa into EternityQwQ:master Aug 2, 2026
2 checks passed
@21Z121Z1
21Z121Z1 deleted the iris-on-metal branch August 11, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants